Compare commits
294 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 690ecc2053 | |||
| 1dbaa66206 | |||
| 41103bbc5a | |||
| f751295449 | |||
| 2444a01fc7 | |||
| 957fdb7dc8 | |||
| ca6196234e | |||
| 2e7aeda684 | |||
| 0cecb1eddf | |||
| dcd845d337 | |||
| f23fa1662e | |||
| d1d50e9c0c | |||
| 586adc33fe | |||
| 4ca8c31e6c | |||
| d7e96edf14 | |||
| d882755485 | |||
| 9b43d483fc | |||
| a41ef0a564 | |||
| aa6d0d0804 | |||
| 43250a4abc | |||
| b1bff57370 | |||
| fe729e026d | |||
| 2fa410ec31 | |||
| e7e408fc20 | |||
| 989ff66102 | |||
| bf20b3a240 | |||
| 146c7481e1 | |||
| baeee3e49b | |||
| 0ce96d9eb4 | |||
| bb5e5150b9 | |||
| 9b287c15ef | |||
| 6f094fcac6 | |||
| bed1f5f0ba | |||
| 825e8063a0 | |||
| 195b430836 | |||
| 3fd19a8bb5 | |||
| af097cafad | |||
| 8e0b696150 | |||
| e55a86ccca | |||
| cad2140da6 | |||
| 9c63c36128 | |||
| e33a2fd9e6 | |||
| 1049216049 | |||
| fde7f6bc95 | |||
| 9409174a07 | |||
| 13a7331f3d | |||
| a8dc565478 | |||
| b44e8babf4 | |||
| 40805e0222 | |||
| 37a175551b | |||
| 6470cb8751 | |||
| 715977beda | |||
| d1c9d21227 | |||
| 36ca822fbc | |||
| 674c103478 | |||
| 61de1bebe9 | |||
| 18208cbcc5 | |||
| 3c827472e1 | |||
| 0a7aafbd51 | |||
| dc2bc7dd83 | |||
| 26854b999c | |||
| aeb20fc565 | |||
| 6593bf41be | |||
| 41d663254e | |||
| 8cb5f98562 | |||
| 7e9a61f877 | |||
| d3d75ccea7 | |||
| c638529dd0 | |||
| da0f39235c | |||
| d0762ec18b | |||
| a3ab38a7c0 | |||
| 9a74e248e9 | |||
| 48fd2f65a0 | |||
| d853f53df8 | |||
| 5f3c3497b8 | |||
| 9471c5c310 | |||
| 6b3dc2de60 | |||
| c8728595a4 | |||
| 16449f1dc1 | |||
| b2ede7f8fd | |||
| 20f6fd9b51 | |||
| f9971253a8 | |||
| 307887366d | |||
| ed68f2f885 | |||
| a8f6c38a6c | |||
| e24a1ddfa0 | |||
| bc3c244052 | |||
| 3a80c35a59 | |||
| e8166e8016 | |||
| e9ccc49d11 | |||
| e20d87a64a | |||
| aab932727b | |||
| 5b1ffa94dd | |||
| 0059c1e418 | |||
| 0653322337 | |||
| 217d0a8ca3 | |||
| ab74f851c8 | |||
| 83acd5a148 | |||
| a044040ada | |||
| 0bff161465 | |||
| 1350e59e6a | |||
| 06fb56075f | |||
| 610e771b9b | |||
| de0b12ba37 | |||
| d66074b740 | |||
| 5e305ae396 | |||
| 6246c886ca | |||
| 0d8500f4d7 | |||
| a1fdea56ba | |||
| b5e0545fbf | |||
| 5fb05c500b | |||
| 930f124aae | |||
| 1b898a1efe | |||
| 1dc336dc47 | |||
| e3b9d8d0c0 | |||
| 6f8528ac54 | |||
| b2aa7b046f | |||
| 5087a7c271 | |||
| 15335703fe | |||
| e9b616ff26 | |||
| f128d3528a | |||
| 8d10372614 | |||
| 383796df87 | |||
| b23f73ecd6 | |||
| ebbc5a13a8 | |||
| 9c544afa20 | |||
| 5c654ad848 | |||
| f855e060d1 | |||
| 8f1b1b88de | |||
| bc6bfdfa27 | |||
| f721d95b32 | |||
| 3d40d76d8e | |||
| fb85e2e57a | |||
| cf0d3e21f1 | |||
| 9daf54b8d9 | |||
| 83e556e89e | |||
| e20693243d | |||
| 001e2a73d5 | |||
| 1cd0ad998f | |||
| 20be752adc | |||
| 819a023163 | |||
| 57cb09bc50 | |||
| bbd1f62062 | |||
| dc3fd77c86 | |||
| dea171862c | |||
| 34be4a5b49 | |||
| 306bfca5d2 | |||
| ce935d9eb5 | |||
| 8477894758 | |||
| 6717843f18 | |||
| 7442d45d9c | |||
| c862a559f6 | |||
| 749a3209d9 | |||
| 15dec06f31 | |||
| 4a1561d85f | |||
| 3ac03a4782 | |||
| 49c03b9673 | |||
| ad2c6dbd53 | |||
| 70a54c98d7 | |||
| 82ac7df0ec | |||
| 2a56ba53ba | |||
| eb187cdc15 | |||
| bec0410ef4 | |||
| 83a400a90e | |||
| 0d3bd47f7a | |||
| ca91af9682 | |||
| a25e5900c7 | |||
| d02f4abb57 | |||
| 264e64bbf5 | |||
| 7891dfb3dd | |||
| 90133cd0e2 | |||
| 59e195af71 | |||
| 36a22202bf | |||
| df90a4a172 | |||
| cf20bd94d0 | |||
| 9a93920b71 | |||
| 474d2215a2 | |||
| e1259b9ca8 | |||
| 2b9b40af93 | |||
| eb427ac608 | |||
| 97e598fe3b | |||
| 9406843988 | |||
| ec827a4ce8 | |||
| c4a93a7f15 | |||
| 0d11315848 | |||
| c336c1c7f8 | |||
| 3b6f66d0fb | |||
| af40d87213 | |||
| efcf7b180c | |||
| f916c26fb4 | |||
| c198d362b1 | |||
| 8524631508 | |||
| 2f56082adc | |||
| 673bba7298 | |||
| 8d8b62f1f5 | |||
| 99f40dd7ea | |||
| bfcf018d33 | |||
| 6c56306873 | |||
| 2cc77f5405 | |||
| 47487c7bab | |||
| fe502fc4b3 | |||
| df86a5f568 | |||
| 3f0662c49a | |||
| 3b6d0c4db5 | |||
| 0605aee88d | |||
| 6fba9d938a | |||
| 5d9ebbbc3e | |||
| 282d8b70fc | |||
| 814494f812 | |||
| 75e5566532 | |||
| 5d382db42e | |||
| 054717fff1 | |||
| 5218c064cb | |||
| 6a24b564a4 | |||
| cc85523c9c | |||
| bb27cb151e | |||
| 16673b91d3 | |||
| ac77e27089 | |||
| 7bd62b3527 | |||
| 402604b4cc | |||
| 4acdf89588 | |||
| 20c0569731 | |||
| 144c813acf | |||
| 328b5b5b15 | |||
| 6472bbf162 | |||
| c66a5b9f64 | |||
| 7e468d51ca | |||
| 7b3f99e837 | |||
| 20704b3cd8 | |||
| 6fa64baa35 | |||
| 6bca898488 | |||
| 8bd90629f9 | |||
| 7eda2fbbe8 | |||
| 5b8200a4ff | |||
| ac6632e24f | |||
| 0ce527c69e | |||
| 8f46ffb695 | |||
| 36558e97cb | |||
| 1cf30a0c8b | |||
| ff4bda1e53 | |||
| af87ef329b | |||
| 9c1d820876 | |||
| cbe215cfc2 | |||
| 12d64bf009 | |||
| 0871068ff8 | |||
| 3d7cee81da | |||
| 0aa5a62754 | |||
| f487713a92 | |||
| 5eb27d1c28 | |||
| 4060115749 | |||
| b98a6761c2 | |||
| 6a8e879898 | |||
| b9407a9f13 | |||
| 53ae1ba76f | |||
| 9a418aa213 | |||
| 9a88b5cad2 | |||
| 3e6a584f4f | |||
| 6ad6167c52 | |||
| f9b84e4d3c | |||
| 2e6fd332ac | |||
| b5376f46e5 | |||
| 98f1fcae14 | |||
| 14c064de64 | |||
| 313c2108a9 | |||
| c12ac16557 | |||
| b686d4506c | |||
| c38bd130e5 | |||
| de343364ad | |||
| e8385adb87 | |||
| f7843e5e35 | |||
| 3a78d09399 | |||
| 28d2032f23 | |||
| 1ec8d734ee | |||
| 60dab1e9df | |||
| 9e175fdf56 | |||
| 13024c5ae8 | |||
| 42b12c5c36 | |||
| a033d15912 | |||
| 1f9bc08682 | |||
| 191381ece4 | |||
| fabdfb121a | |||
| 4454adca59 | |||
| 9ae417cb03 | |||
| 367aa4c67b | |||
| cac3768885 | |||
| b953908f58 | |||
| 0ffb1c771e | |||
| 8858a08a32 | |||
| faa36d0e5e | |||
| 25766959f1 | |||
| 5f949121bf | |||
| a22c238dc4 | |||
| 9363a2dd99 | |||
| b607a9cd8a |
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# Ignore Visual Studio + build artifacts
|
||||||
|
.vs/
|
||||||
|
TrafagSalesExporter/.vs/
|
||||||
|
TrafagSalesExporter/bin/
|
||||||
|
TrafagSalesExporter/obj/
|
||||||
|
TrafagSalesExporter/*.user
|
||||||
|
TrafagSalesExporter/*.suo
|
||||||
|
TrafagSalesExporter/*.db
|
||||||
|
TrafagSalesExporter/*.db-shm
|
||||||
|
TrafagSalesExporter/*.db-wal
|
||||||
|
Trafag/
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import HealthKit
|
||||||
|
import BackgroundTasks
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct HealthBridgeApp: App {
|
||||||
|
@StateObject private var appState = AppState()
|
||||||
|
@StateObject private var healthKitManager = HealthKitManager.shared
|
||||||
|
@StateObject private var syncCoordinator = SyncCoordinator.shared
|
||||||
|
|
||||||
|
init() {
|
||||||
|
registerBackgroundTasks()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.environmentObject(appState)
|
||||||
|
.environmentObject(healthKitManager)
|
||||||
|
.environmentObject(syncCoordinator)
|
||||||
|
.onAppear {
|
||||||
|
Task {
|
||||||
|
await requestHealthKitAuthorization()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func registerBackgroundTasks() {
|
||||||
|
BGTaskScheduler.shared.register(
|
||||||
|
forTaskWithIdentifier: "com.healthbridge.sync",
|
||||||
|
using: nil
|
||||||
|
) { task in
|
||||||
|
guard let bgTask = task as? BGAppRefreshTask else { return }
|
||||||
|
handleBackgroundSync(task: bgTask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleBackgroundSync(task: BGAppRefreshTask) {
|
||||||
|
scheduleNextBackgroundSync()
|
||||||
|
|
||||||
|
let syncTask = Task {
|
||||||
|
do {
|
||||||
|
try await syncCoordinator.performSync()
|
||||||
|
task.setTaskCompleted(success: true)
|
||||||
|
} catch {
|
||||||
|
task.setTaskCompleted(success: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task.expirationHandler = {
|
||||||
|
syncTask.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleNextBackgroundSync() {
|
||||||
|
let request = BGAppRefreshTaskRequest(identifier: "com.healthbridge.sync")
|
||||||
|
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 min
|
||||||
|
|
||||||
|
do {
|
||||||
|
try BGTaskScheduler.shared.submit(request)
|
||||||
|
} catch {
|
||||||
|
print("Failed to schedule background sync: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestHealthKitAuthorization() async {
|
||||||
|
do {
|
||||||
|
try await healthKitManager.requestAuthorization()
|
||||||
|
} catch {
|
||||||
|
print("HealthKit authorization failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - App State
|
||||||
|
@MainActor
|
||||||
|
class AppState: ObservableObject {
|
||||||
|
@Published var selectedTab: Tab = .dashboard
|
||||||
|
@Published var showingConflictDetail: Conflict?
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var lastSyncDate: Date?
|
||||||
|
@Published var pendingConflicts: [Conflict] = []
|
||||||
|
|
||||||
|
enum Tab {
|
||||||
|
case dashboard
|
||||||
|
case conflicts
|
||||||
|
case rules
|
||||||
|
case sources
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Conflict
|
||||||
|
struct Conflict: Identifiable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let timeWindow: TimeWindow
|
||||||
|
var readings: [SourceReading]
|
||||||
|
var status: ConflictStatus
|
||||||
|
var resolution: ConflictResolution?
|
||||||
|
var appliedStrategy: MergeStrategy?
|
||||||
|
let detectedAt: Date
|
||||||
|
var resolvedAt: Date?
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
dataType: HealthDataType,
|
||||||
|
timeWindow: TimeWindow,
|
||||||
|
readings: [SourceReading],
|
||||||
|
status: ConflictStatus = .pending,
|
||||||
|
resolution: ConflictResolution? = nil,
|
||||||
|
appliedStrategy: MergeStrategy? = nil,
|
||||||
|
detectedAt: Date = Date(),
|
||||||
|
resolvedAt: Date? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.dataType = dataType
|
||||||
|
self.timeWindow = timeWindow
|
||||||
|
self.readings = readings
|
||||||
|
self.status = status
|
||||||
|
self.resolution = resolution
|
||||||
|
self.appliedStrategy = appliedStrategy
|
||||||
|
self.detectedAt = detectedAt
|
||||||
|
self.resolvedAt = resolvedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
var valueDifference: Double {
|
||||||
|
guard readings.count >= 2 else { return 0 }
|
||||||
|
let values = readings.map { $0.value }
|
||||||
|
return (values.max() ?? 0) - (values.min() ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var percentageDifference: Double {
|
||||||
|
guard readings.count >= 2 else { return 0 }
|
||||||
|
let values = readings.map { $0.value }
|
||||||
|
guard let min = values.min(), min > 0 else { return 0 }
|
||||||
|
guard let max = values.max() else { return 0 }
|
||||||
|
return ((max - min) / min) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
var severity: ConflictSeverity {
|
||||||
|
let pctDiff = percentageDifference
|
||||||
|
if pctDiff < 5 { return .minor }
|
||||||
|
if pctDiff < 20 { return .moderate }
|
||||||
|
if pctDiff < 50 { return .significant }
|
||||||
|
return .major
|
||||||
|
}
|
||||||
|
|
||||||
|
var highestValueReading: SourceReading? {
|
||||||
|
readings.max(by: { $0.value < $1.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
var lowestValueReading: SourceReading? {
|
||||||
|
readings.min(by: { $0.value < $1.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
var primarySourceReading: SourceReading? {
|
||||||
|
readings.max(by: { $0.sourceCategory.priority < $1.sourceCategory.priority })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Status
|
||||||
|
enum ConflictStatus: String, Codable {
|
||||||
|
case pending = "pending"
|
||||||
|
case resolved = "resolved"
|
||||||
|
case manualReview = "manual_review"
|
||||||
|
case ignored = "ignored"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .pending: return "Offen"
|
||||||
|
case .resolved: return "Gelöst"
|
||||||
|
case .manualReview: return "Manuelle Prüfung"
|
||||||
|
case .ignored: return "Ignoriert"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .pending: return "clock.fill"
|
||||||
|
case .resolved: return "checkmark.circle.fill"
|
||||||
|
case .manualReview: return "hand.raised.fill"
|
||||||
|
case .ignored: return "eye.slash.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Severity
|
||||||
|
enum ConflictSeverity: String, Codable {
|
||||||
|
case minor = "minor"
|
||||||
|
case moderate = "moderate"
|
||||||
|
case significant = "significant"
|
||||||
|
case major = "major"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .minor: return "Gering"
|
||||||
|
case .moderate: return "Moderat"
|
||||||
|
case .significant: return "Erheblich"
|
||||||
|
case .major: return "Gross"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var color: String {
|
||||||
|
switch self {
|
||||||
|
case .minor: return "green"
|
||||||
|
case .moderate: return "yellow"
|
||||||
|
case .significant: return "orange"
|
||||||
|
case .major: return "red"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Resolution
|
||||||
|
struct ConflictResolution: Codable {
|
||||||
|
let resolvedValue: Double
|
||||||
|
let secondaryResolvedValue: Double? // For blood pressure
|
||||||
|
let winningSourceId: String
|
||||||
|
let strategy: MergeStrategy
|
||||||
|
let isManual: Bool
|
||||||
|
let resolvedAt: Date
|
||||||
|
let notes: String?
|
||||||
|
|
||||||
|
init(
|
||||||
|
resolvedValue: Double,
|
||||||
|
secondaryResolvedValue: Double? = nil,
|
||||||
|
winningSourceId: String,
|
||||||
|
strategy: MergeStrategy,
|
||||||
|
isManual: Bool = false,
|
||||||
|
resolvedAt: Date = Date(),
|
||||||
|
notes: String? = nil
|
||||||
|
) {
|
||||||
|
self.resolvedValue = resolvedValue
|
||||||
|
self.secondaryResolvedValue = secondaryResolvedValue
|
||||||
|
self.winningSourceId = winningSourceId
|
||||||
|
self.strategy = strategy
|
||||||
|
self.isManual = isManual
|
||||||
|
self.resolvedAt = resolvedAt
|
||||||
|
self.notes = notes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Merge Strategy
|
||||||
|
enum MergeStrategy: String, Codable, CaseIterable, Identifiable {
|
||||||
|
case exclusive = "exclusive"
|
||||||
|
case priority = "priority"
|
||||||
|
case higherWins = "higher_wins"
|
||||||
|
case lowerWins = "lower_wins"
|
||||||
|
case average = "average"
|
||||||
|
case coverage = "coverage"
|
||||||
|
case coverageThenHigher = "coverage_then_higher"
|
||||||
|
case manual = "manual"
|
||||||
|
case mostRecent = "most_recent"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .exclusive: return "Exklusiv"
|
||||||
|
case .priority: return "Priorität"
|
||||||
|
case .higherWins: return "Höherer Wert"
|
||||||
|
case .lowerWins: return "Niedrigerer Wert"
|
||||||
|
case .average: return "Durchschnitt"
|
||||||
|
case .coverage: return "Abdeckung"
|
||||||
|
case .coverageThenHigher: return "Abdeckung + Höher"
|
||||||
|
case .manual: return "Manuell"
|
||||||
|
case .mostRecent: return "Neuester"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .exclusive:
|
||||||
|
return "Nur eine Quelle kann diesen Datentyp liefern"
|
||||||
|
case .priority:
|
||||||
|
return "Höchste Priorität gewinnt basierend auf Benutzereinstellungen"
|
||||||
|
case .higherWins:
|
||||||
|
return "Der grössere Wert wird verwendet (z.B. mehr Schritte = war aktiv)"
|
||||||
|
case .lowerWins:
|
||||||
|
return "Der kleinere Wert wird verwendet"
|
||||||
|
case .average:
|
||||||
|
return "Durchschnitt aller Quellen"
|
||||||
|
case .coverage:
|
||||||
|
return "Quelle mit Daten für dieses Zeitfenster"
|
||||||
|
case .coverageThenHigher:
|
||||||
|
return "Erst Abdeckung prüfen, dann höherer Wert bei Konflikt"
|
||||||
|
case .manual:
|
||||||
|
return "Benutzer entscheidet bei jedem Konflikt"
|
||||||
|
case .mostRecent:
|
||||||
|
return "Zuletzt erfasster Wert"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .exclusive: return "1.circle.fill"
|
||||||
|
case .priority: return "list.number"
|
||||||
|
case .higherWins: return "arrow.up.circle.fill"
|
||||||
|
case .lowerWins: return "arrow.down.circle.fill"
|
||||||
|
case .average: return "divide.circle.fill"
|
||||||
|
case .coverage: return "square.fill.on.square.fill"
|
||||||
|
case .coverageThenHigher: return "square.stack.3d.up.fill"
|
||||||
|
case .manual: return "hand.raised.fill"
|
||||||
|
case .mostRecent: return "clock.arrow.circlepath"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Merge Rule
|
||||||
|
struct MergeRule: Identifiable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let dataType: HealthDataType
|
||||||
|
var strategy: MergeStrategy
|
||||||
|
var primarySourceId: String?
|
||||||
|
var fallbackSourceId: String?
|
||||||
|
var sourcePriorities: [String: Int]
|
||||||
|
var autoApply: Bool
|
||||||
|
var thresholdForManualReview: Double? // Percentage difference threshold
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
dataType: HealthDataType,
|
||||||
|
strategy: MergeStrategy,
|
||||||
|
primarySourceId: String? = nil,
|
||||||
|
fallbackSourceId: String? = nil,
|
||||||
|
sourcePriorities: [String: Int] = [:],
|
||||||
|
autoApply: Bool = true,
|
||||||
|
thresholdForManualReview: Double? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.dataType = dataType
|
||||||
|
self.strategy = strategy
|
||||||
|
self.primarySourceId = primarySourceId
|
||||||
|
self.fallbackSourceId = fallbackSourceId
|
||||||
|
self.sourcePriorities = sourcePriorities
|
||||||
|
self.autoApply = autoApply
|
||||||
|
self.thresholdForManualReview = thresholdForManualReview
|
||||||
|
}
|
||||||
|
|
||||||
|
static func defaultRule(for dataType: HealthDataType) -> MergeRule {
|
||||||
|
switch dataType {
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic, .bloodOxygen,
|
||||||
|
.heartRate, .restingHeartRate, .heartRateVariability, .respiratoryRate:
|
||||||
|
return MergeRule(dataType: dataType, strategy: .exclusive)
|
||||||
|
case .floorsClimbed:
|
||||||
|
return MergeRule(dataType: dataType, strategy: .exclusive)
|
||||||
|
case .steps, .distance, .activeEnergy:
|
||||||
|
return MergeRule(dataType: dataType, strategy: .coverageThenHigher)
|
||||||
|
case .sleep:
|
||||||
|
return MergeRule(dataType: dataType, strategy: .priority)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync Record
|
||||||
|
struct SyncRecord: Identifiable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let timeWindow: TimeWindow
|
||||||
|
var readings: [SourceReading]
|
||||||
|
var mergedValue: Double?
|
||||||
|
var secondaryMergedValue: Double? // For blood pressure
|
||||||
|
var strategy: MergeStrategy
|
||||||
|
var status: SyncStatus
|
||||||
|
var hasConflict: Bool
|
||||||
|
var conflictId: UUID?
|
||||||
|
let createdAt: Date
|
||||||
|
var processedAt: Date?
|
||||||
|
|
||||||
|
enum SyncStatus: String, Codable {
|
||||||
|
case pending = "pending"
|
||||||
|
case processing = "processing"
|
||||||
|
case completed = "completed"
|
||||||
|
case failed = "failed"
|
||||||
|
case requiresManualReview = "requires_manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
dataType: HealthDataType,
|
||||||
|
timeWindow: TimeWindow,
|
||||||
|
readings: [SourceReading],
|
||||||
|
mergedValue: Double? = nil,
|
||||||
|
secondaryMergedValue: Double? = nil,
|
||||||
|
strategy: MergeStrategy = .priority,
|
||||||
|
status: SyncStatus = .pending,
|
||||||
|
hasConflict: Bool = false,
|
||||||
|
conflictId: UUID? = nil,
|
||||||
|
createdAt: Date = Date(),
|
||||||
|
processedAt: Date? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.dataType = dataType
|
||||||
|
self.timeWindow = timeWindow
|
||||||
|
self.readings = readings
|
||||||
|
self.mergedValue = mergedValue
|
||||||
|
self.secondaryMergedValue = secondaryMergedValue
|
||||||
|
self.strategy = strategy
|
||||||
|
self.status = status
|
||||||
|
self.hasConflict = hasConflict
|
||||||
|
self.conflictId = conflictId
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.processedAt = processedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import Foundation
|
||||||
|
import HealthKit
|
||||||
|
|
||||||
|
// MARK: - Health Data Type
|
||||||
|
enum HealthDataType: String, CaseIterable, Codable, Identifiable {
|
||||||
|
case steps = "steps"
|
||||||
|
case heartRate = "heart_rate"
|
||||||
|
case bloodPressureSystolic = "blood_pressure_systolic"
|
||||||
|
case bloodPressureDiastolic = "blood_pressure_diastolic"
|
||||||
|
case bloodOxygen = "blood_oxygen"
|
||||||
|
case sleep = "sleep"
|
||||||
|
case distance = "distance"
|
||||||
|
case floorsClimbed = "floors_climbed"
|
||||||
|
case activeEnergy = "active_energy"
|
||||||
|
case restingHeartRate = "resting_heart_rate"
|
||||||
|
case heartRateVariability = "hrv"
|
||||||
|
case respiratoryRate = "respiratory_rate"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .steps: return "Schritte"
|
||||||
|
case .heartRate: return "Herzfrequenz"
|
||||||
|
case .bloodPressureSystolic: return "Blutdruck (Systolisch)"
|
||||||
|
case .bloodPressureDiastolic: return "Blutdruck (Diastolisch)"
|
||||||
|
case .bloodOxygen: return "Blutsauerstoff (SpO2)"
|
||||||
|
case .sleep: return "Schlaf"
|
||||||
|
case .distance: return "Distanz"
|
||||||
|
case .floorsClimbed: return "Stockwerke"
|
||||||
|
case .activeEnergy: return "Aktive Energie"
|
||||||
|
case .restingHeartRate: return "Ruhepuls"
|
||||||
|
case .heartRateVariability: return "HRV"
|
||||||
|
case .respiratoryRate: return "Atemfrequenz"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .steps: return "figure.walk"
|
||||||
|
case .heartRate, .restingHeartRate: return "heart.fill"
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic: return "drop.fill"
|
||||||
|
case .bloodOxygen: return "lungs.fill"
|
||||||
|
case .sleep: return "bed.double.fill"
|
||||||
|
case .distance: return "map.fill"
|
||||||
|
case .floorsClimbed: return "stairs"
|
||||||
|
case .activeEnergy: return "flame.fill"
|
||||||
|
case .heartRateVariability: return "waveform.path.ecg"
|
||||||
|
case .respiratoryRate: return "wind"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var unit: String {
|
||||||
|
switch self {
|
||||||
|
case .steps: return "Schritte"
|
||||||
|
case .heartRate, .restingHeartRate: return "bpm"
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic: return "mmHg"
|
||||||
|
case .bloodOxygen: return "%"
|
||||||
|
case .sleep: return "h"
|
||||||
|
case .distance: return "km"
|
||||||
|
case .floorsClimbed: return "Stockwerke"
|
||||||
|
case .activeEnergy: return "kcal"
|
||||||
|
case .heartRateVariability: return "ms"
|
||||||
|
case .respiratoryRate: return "/min"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hkQuantityType: HKQuantityType? {
|
||||||
|
switch self {
|
||||||
|
case .steps:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .stepCount)
|
||||||
|
case .heartRate:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .heartRate)
|
||||||
|
case .bloodPressureSystolic:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .bloodPressureSystolic)
|
||||||
|
case .bloodPressureDiastolic:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .bloodPressureDiastolic)
|
||||||
|
case .bloodOxygen:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .oxygenSaturation)
|
||||||
|
case .distance:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning)
|
||||||
|
case .floorsClimbed:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .flightsClimbed)
|
||||||
|
case .activeEnergy:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)
|
||||||
|
case .restingHeartRate:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .restingHeartRate)
|
||||||
|
case .heartRateVariability:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN)
|
||||||
|
case .respiratoryRate:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .respiratoryRate)
|
||||||
|
case .sleep:
|
||||||
|
return nil // Sleep uses category type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hkCategoryType: HKCategoryType? {
|
||||||
|
switch self {
|
||||||
|
case .sleep:
|
||||||
|
return HKCategoryType.categoryType(forIdentifier: .sleepAnalysis)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hkUnit: HKUnit {
|
||||||
|
switch self {
|
||||||
|
case .steps, .floorsClimbed:
|
||||||
|
return .count()
|
||||||
|
case .heartRate, .restingHeartRate, .respiratoryRate:
|
||||||
|
return HKUnit.count().unitDivided(by: .minute())
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic:
|
||||||
|
return .millimeterOfMercury()
|
||||||
|
case .bloodOxygen:
|
||||||
|
return .percent()
|
||||||
|
case .sleep:
|
||||||
|
return .hour()
|
||||||
|
case .distance:
|
||||||
|
return .meterUnit(with: .kilo)
|
||||||
|
case .activeEnergy:
|
||||||
|
return .kilocalorie()
|
||||||
|
case .heartRateVariability:
|
||||||
|
return .secondUnit(with: .milli)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default primary source for this data type
|
||||||
|
var defaultPrimarySource: SourceCategory {
|
||||||
|
switch self {
|
||||||
|
case .floorsClimbed:
|
||||||
|
return .iPhone
|
||||||
|
case .steps, .heartRate, .bloodPressureSystolic, .bloodPressureDiastolic,
|
||||||
|
.bloodOxygen, .sleep, .distance, .activeEnergy, .restingHeartRate,
|
||||||
|
.heartRateVariability, .respiratoryRate:
|
||||||
|
return .watch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this data type typically has only one source
|
||||||
|
var isExclusive: Bool {
|
||||||
|
switch self {
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic, .bloodOxygen,
|
||||||
|
.heartRate, .restingHeartRate, .heartRateVariability, .respiratoryRate, .sleep:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Category
|
||||||
|
enum SourceCategory: String, Codable, CaseIterable {
|
||||||
|
case iPhone = "iphone"
|
||||||
|
case watch = "watch"
|
||||||
|
case thirdPartyWatch = "third_party_watch"
|
||||||
|
case thirdPartyApp = "third_party_app"
|
||||||
|
case healthBridge = "health_bridge"
|
||||||
|
case unknown = "unknown"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .iPhone: return "iPhone"
|
||||||
|
case .watch: return "Apple Watch"
|
||||||
|
case .thirdPartyWatch: return "Drittanbieter-Watch"
|
||||||
|
case .thirdPartyApp: return "Drittanbieter-App"
|
||||||
|
case .healthBridge: return "HealthBridge"
|
||||||
|
case .unknown: return "Unbekannt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .iPhone: return "iphone"
|
||||||
|
case .watch: return "applewatch"
|
||||||
|
case .thirdPartyWatch: return "applewatch.side.right"
|
||||||
|
case .thirdPartyApp: return "app.badge"
|
||||||
|
case .healthBridge: return "arrow.triangle.2.circlepath"
|
||||||
|
case .unknown: return "questionmark.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var priority: Int {
|
||||||
|
switch self {
|
||||||
|
case .healthBridge: return 100
|
||||||
|
case .watch: return 80
|
||||||
|
case .thirdPartyWatch: return 70
|
||||||
|
case .iPhone: return 50
|
||||||
|
case .thirdPartyApp: return 30
|
||||||
|
case .unknown: return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Quality
|
||||||
|
enum DataQuality: String, Codable {
|
||||||
|
case complete = "complete"
|
||||||
|
case partial = "partial"
|
||||||
|
case missing = "missing"
|
||||||
|
case invalid = "invalid"
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .complete: return "checkmark.circle.fill"
|
||||||
|
case .partial: return "circle.lefthalf.filled"
|
||||||
|
case .missing: return "circle.dashed"
|
||||||
|
case .invalid: return "xmark.circle.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var color: String {
|
||||||
|
switch self {
|
||||||
|
case .complete: return "green"
|
||||||
|
case .partial: return "yellow"
|
||||||
|
case .missing: return "gray"
|
||||||
|
case .invalid: return "red"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Time Window
|
||||||
|
struct TimeWindow: Codable, Hashable, Identifiable {
|
||||||
|
let start: Date
|
||||||
|
let end: Date
|
||||||
|
|
||||||
|
var id: String { "\(start.timeIntervalSince1970)-\(end.timeIntervalSince1970)" }
|
||||||
|
|
||||||
|
var interval: DateInterval {
|
||||||
|
DateInterval(start: start, end: end)
|
||||||
|
}
|
||||||
|
|
||||||
|
var duration: TimeInterval {
|
||||||
|
end.timeIntervalSince(start)
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedRange: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .none
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
return "\(formatter.string(from: start)) - \(formatter.string(from: end))"
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedDate: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .medium
|
||||||
|
formatter.timeStyle = .none
|
||||||
|
return formatter.string(from: start)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func windows(for date: Date, intervalMinutes: Int = 15) -> [TimeWindow] {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let startOfDay = calendar.startOfDay(for: date)
|
||||||
|
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
|
||||||
|
|
||||||
|
var windows: [TimeWindow] = []
|
||||||
|
var current = startOfDay
|
||||||
|
|
||||||
|
while current < endOfDay {
|
||||||
|
let windowEnd = calendar.date(byAdding: .minute, value: intervalMinutes, to: current)!
|
||||||
|
windows.append(TimeWindow(start: current, end: min(windowEnd, endOfDay)))
|
||||||
|
current = windowEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
return windows
|
||||||
|
}
|
||||||
|
|
||||||
|
static func hourlyWindows(for date: Date) -> [TimeWindow] {
|
||||||
|
windows(for: date, intervalMinutes: 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import Foundation
|
||||||
|
import HealthKit
|
||||||
|
|
||||||
|
// MARK: - Health Source
|
||||||
|
struct HealthSource: Identifiable, Codable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let bundleIdentifier: String
|
||||||
|
let name: String
|
||||||
|
let category: SourceCategory
|
||||||
|
var supportedDataTypes: Set<HealthDataType>
|
||||||
|
var lastActivityDate: Date?
|
||||||
|
var userPriorities: [HealthDataType: Int]
|
||||||
|
var isEnabled: Bool
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: String = UUID().uuidString,
|
||||||
|
bundleIdentifier: String,
|
||||||
|
name: String,
|
||||||
|
category: SourceCategory,
|
||||||
|
supportedDataTypes: Set<HealthDataType> = [],
|
||||||
|
lastActivityDate: Date? = nil,
|
||||||
|
userPriorities: [HealthDataType: Int] = [:],
|
||||||
|
isEnabled: Bool = true
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.bundleIdentifier = bundleIdentifier
|
||||||
|
self.name = name
|
||||||
|
self.category = category
|
||||||
|
self.supportedDataTypes = supportedDataTypes
|
||||||
|
self.lastActivityDate = lastActivityDate
|
||||||
|
self.userPriorities = userPriorities
|
||||||
|
self.isEnabled = isEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
if name.isEmpty {
|
||||||
|
return bundleIdentifier.components(separatedBy: ".").last ?? bundleIdentifier
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
var isHealthBridge: Bool {
|
||||||
|
bundleIdentifier == HealthBridgeConstants.bundleIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
func priority(for dataType: HealthDataType) -> Int {
|
||||||
|
userPriorities[dataType] ?? category.priority
|
||||||
|
}
|
||||||
|
|
||||||
|
static func from(hkSource: HKSource) -> HealthSource {
|
||||||
|
let category = classifySource(bundleId: hkSource.bundleIdentifier)
|
||||||
|
return HealthSource(
|
||||||
|
id: hkSource.bundleIdentifier,
|
||||||
|
bundleIdentifier: hkSource.bundleIdentifier,
|
||||||
|
name: hkSource.name,
|
||||||
|
category: category
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func classifySource(bundleId: String) -> SourceCategory {
|
||||||
|
let lowercased = bundleId.lowercased()
|
||||||
|
|
||||||
|
if lowercased.contains("healthbridge") {
|
||||||
|
return .healthBridge
|
||||||
|
} else if lowercased.contains("apple.health") {
|
||||||
|
return .iPhone
|
||||||
|
} else if lowercased.contains("watch") || lowercased.contains("applewatch") {
|
||||||
|
return .watch
|
||||||
|
} else if lowercased.contains("huawei") || lowercased.contains("samsung") ||
|
||||||
|
lowercased.contains("fitbit") || lowercased.contains("garmin") ||
|
||||||
|
lowercased.contains("polar") || lowercased.contains("withings") {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
} else {
|
||||||
|
return .thirdPartyApp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Reading
|
||||||
|
struct SourceReading: Identifiable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let sourceId: String
|
||||||
|
let sourceName: String
|
||||||
|
let sourceCategory: SourceCategory
|
||||||
|
let value: Double
|
||||||
|
let secondaryValue: Double? // For blood pressure (diastolic)
|
||||||
|
let timestamp: Date
|
||||||
|
let originalRecordId: String?
|
||||||
|
let quality: DataQuality
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
sourceId: String,
|
||||||
|
sourceName: String,
|
||||||
|
sourceCategory: SourceCategory,
|
||||||
|
value: Double,
|
||||||
|
secondaryValue: Double? = nil,
|
||||||
|
timestamp: Date,
|
||||||
|
originalRecordId: String? = nil,
|
||||||
|
quality: DataQuality = .complete
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.sourceId = sourceId
|
||||||
|
self.sourceName = sourceName
|
||||||
|
self.sourceCategory = sourceCategory
|
||||||
|
self.value = value
|
||||||
|
self.secondaryValue = secondaryValue
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.originalRecordId = originalRecordId
|
||||||
|
self.quality = quality
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedValue: String {
|
||||||
|
if value == floor(value) {
|
||||||
|
return String(format: "%.0f", value)
|
||||||
|
}
|
||||||
|
return String(format: "%.1f", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Health Status
|
||||||
|
struct SourceHealthStatus: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let source: HealthSource
|
||||||
|
let lastSync: Date?
|
||||||
|
let recordCount: Int
|
||||||
|
let dataGaps: [TimeWindow]
|
||||||
|
let overallQuality: DataQuality
|
||||||
|
|
||||||
|
var syncStatus: SyncStatus {
|
||||||
|
guard let lastSync = lastSync else {
|
||||||
|
return .neverSynced
|
||||||
|
}
|
||||||
|
|
||||||
|
let hoursSinceSync = Date().timeIntervalSince(lastSync) / 3600
|
||||||
|
|
||||||
|
if hoursSinceSync < 1 {
|
||||||
|
return .recentlySynced
|
||||||
|
} else if hoursSinceSync < 24 {
|
||||||
|
return .syncedToday
|
||||||
|
} else if hoursSinceSync < 72 {
|
||||||
|
return .stale
|
||||||
|
} else {
|
||||||
|
return .veryStale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SyncStatus {
|
||||||
|
case recentlySynced
|
||||||
|
case syncedToday
|
||||||
|
case stale
|
||||||
|
case veryStale
|
||||||
|
case neverSynced
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .recentlySynced: return "checkmark.circle.fill"
|
||||||
|
case .syncedToday: return "checkmark.circle"
|
||||||
|
case .stale: return "exclamationmark.circle"
|
||||||
|
case .veryStale: return "exclamationmark.triangle"
|
||||||
|
case .neverSynced: return "xmark.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .recentlySynced: return "Kürzlich synchronisiert"
|
||||||
|
case .syncedToday: return "Heute synchronisiert"
|
||||||
|
case .stale: return "Sync überfällig"
|
||||||
|
case .veryStale: return "Lange nicht synchronisiert"
|
||||||
|
case .neverSynced: return "Nie synchronisiert"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Constants
|
||||||
|
enum HealthBridgeConstants {
|
||||||
|
static let bundleIdentifier = "com.healthbridge.merged"
|
||||||
|
static let displayName = "HealthBridge"
|
||||||
|
static let defaultSyncInterval: TimeInterval = 15 * 60 // 15 minutes
|
||||||
|
static let conflictThreshold: TimeInterval = 60 // 1 minute overlap tolerance
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# HealthBridge
|
||||||
|
|
||||||
|
Intelligente Health-Daten-Synchronisation für iOS – Eine "Single Source of Truth" für Gesundheitsdaten.
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
HealthBridge liest alle Quellen aus Apple Health, erkennt Konflikte zwischen verschiedenen Geräten und Apps, merged intelligent basierend auf konfigurierbaren Regeln und schreibt bereinigte Daten zurück.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Source Discovery
|
||||||
|
- Automatische Erkennung aller verbundenen Datenquellen
|
||||||
|
- Klassifizierung nach Gerätetyp (iPhone, Apple Watch, Drittanbieter-Watch, Apps)
|
||||||
|
- Übersicht über Fähigkeiten und unterstützte Datentypen pro Quelle
|
||||||
|
|
||||||
|
### Konflikt-Erkennung
|
||||||
|
- Automatische Erkennung von Datenkonflikten zwischen Quellen
|
||||||
|
- Zeitfenster-basierte Analyse (15-Minuten-Intervalle)
|
||||||
|
- Schweregrad-Klassifizierung (minor, moderate, significant, major)
|
||||||
|
|
||||||
|
### Merge-Strategien
|
||||||
|
- **Exclusive**: Nur eine Quelle möglich (z.B. Blutdruck, SpO2)
|
||||||
|
- **Priority**: Fixe Rangfolge basierend auf Benutzereinstellungen
|
||||||
|
- **Higher Wins**: Grösserer Wert gewinnt (ideal für Schritte)
|
||||||
|
- **Coverage**: Quelle mit Daten für Zeitfenster gewinnt
|
||||||
|
- **Coverage Then Higher**: Erst Abdeckung, dann höherer Wert
|
||||||
|
- **Average**: Durchschnitt aller Quellen
|
||||||
|
- **Manual**: Benutzer entscheidet bei jedem Konflikt
|
||||||
|
|
||||||
|
### UI-Komponenten
|
||||||
|
- **Dashboard**: Tagesübersicht aller Gesundheitsdaten mit Sync-Status
|
||||||
|
- **Konflikte**: Liste offener Konflikte mit One-Tap-Auflösung
|
||||||
|
- **Regeln**: Konfiguration der Merge-Strategien pro Datentyp
|
||||||
|
- **Quellen**: Übersicht aller erkannten Datenquellen
|
||||||
|
|
||||||
|
### Background Sync
|
||||||
|
- Automatische Synchronisierung im Hintergrund
|
||||||
|
- Konfigurierbares Intervall (15 Min bis 2 Stunden)
|
||||||
|
- Push-Benachrichtigungen bei neuen Konflikten
|
||||||
|
|
||||||
|
## Unterstützte Datentypen
|
||||||
|
|
||||||
|
| Datentyp | Primärquelle | Strategie |
|
||||||
|
|----------|--------------|-----------|
|
||||||
|
| Schritte | Watch | Coverage + Higher |
|
||||||
|
| Herzfrequenz | Watch | Exclusive |
|
||||||
|
| Blutdruck | Watch D2 | Exclusive |
|
||||||
|
| SpO2 | Watch | Exclusive |
|
||||||
|
| Schlaf | Watch | Priority |
|
||||||
|
| Distanz | Watch/iPhone | Coverage + Higher |
|
||||||
|
| Stockwerke | iPhone | Exclusive |
|
||||||
|
| Aktive Energie | Watch | Coverage + Higher |
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Apple Health │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
▲
|
||||||
|
│
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ HealthBridge App │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ DataReader │→ │ MergeEngine │→ │ DataWriter │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||||
|
│ ▲ ▲ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │SourceManager│ │ RuleEngine │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ SyncCoordinator │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
HealthBridge/
|
||||||
|
├── App/
|
||||||
|
│ └── HealthBridgeApp.swift # App-Entry, Background Tasks
|
||||||
|
├── Models/
|
||||||
|
│ ├── HealthDataTypes.swift # Datentypen, TimeWindow
|
||||||
|
│ ├── Source.swift # HealthSource, SourceReading
|
||||||
|
│ └── Conflict.swift # Conflict, MergeStrategy, MergeRule
|
||||||
|
├── Services/
|
||||||
|
│ ├── HealthKitManager.swift # HealthKit-Integration
|
||||||
|
│ ├── SourceManager.swift # Source Discovery & Management
|
||||||
|
│ ├── DataReader.swift # Daten lesen, Konflikte erkennen
|
||||||
|
│ ├── RuleEngine.swift # Merge-Regeln verwalten & anwenden
|
||||||
|
│ ├── MergeEngine.swift # Konflikte analysieren & lösen
|
||||||
|
│ ├── DataWriter.swift # Daten zurückschreiben
|
||||||
|
│ └── SyncCoordinator.swift # Orchestrierung aller Services
|
||||||
|
├── Views/
|
||||||
|
│ ├── ContentView.swift # Tab-Navigation
|
||||||
|
│ ├── DashboardView.swift # Hauptübersicht
|
||||||
|
│ ├── ConflictsView.swift # Konflikt-Liste & Detail
|
||||||
|
│ ├── RulesView.swift # Regelwerk-Editor
|
||||||
|
│ ├── SourcesView.swift # Quellen-Übersicht
|
||||||
|
│ ├── SettingsView.swift # Einstellungen
|
||||||
|
│ └── Components/
|
||||||
|
│ └── HealthChart.swift # Diagramm-Komponenten
|
||||||
|
├── ViewModels/
|
||||||
|
│ └── DashboardViewModel.swift # Dashboard-Logik
|
||||||
|
├── Utils/
|
||||||
|
│ ├── Extensions.swift # Swift-Erweiterungen
|
||||||
|
│ └── NotificationManager.swift # Push-Benachrichtigungen
|
||||||
|
└── Resources/
|
||||||
|
├── Info.plist # App-Konfiguration
|
||||||
|
└── HealthBridge.entitlements # HealthKit-Berechtigungen
|
||||||
|
```
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- iOS 16.0+
|
||||||
|
- Xcode 15.0+
|
||||||
|
- Apple Developer Account (für HealthKit-Entitlements)
|
||||||
|
- Physisches Gerät (HealthKit nicht im Simulator verfügbar)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Projekt in Xcode öffnen
|
||||||
|
2. Team für Code Signing auswählen
|
||||||
|
3. HealthKit-Capability aktivieren
|
||||||
|
4. Auf physischem Gerät ausführen
|
||||||
|
|
||||||
|
## Berechtigungen
|
||||||
|
|
||||||
|
Die App benötigt folgende Berechtigungen:
|
||||||
|
- **HealthKit Read**: Lesen aller Gesundheitsdaten
|
||||||
|
- **HealthKit Write**: Schreiben gemergter Daten
|
||||||
|
- **Background App Refresh**: Für automatische Synchronisierung
|
||||||
|
- **Notifications**: Für Konflikt-Benachrichtigungen
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
MIT License
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.developer.healthkit</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.developer.healthkit.access</key>
|
||||||
|
<array>
|
||||||
|
<string>health-records</string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.developer.healthkit.background-delivery</key>
|
||||||
|
<true/>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>HealthBridge</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIColorName</key>
|
||||||
|
<string>LaunchScreenBackground</string>
|
||||||
|
<key>UIImageName</key>
|
||||||
|
<string>LaunchIcon</string>
|
||||||
|
</dict>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
|
<array>
|
||||||
|
<string>armv7</string>
|
||||||
|
<string>healthkit</string>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<!-- HealthKit -->
|
||||||
|
<key>NSHealthShareUsageDescription</key>
|
||||||
|
<string>HealthBridge benötigt Zugriff auf Ihre Gesundheitsdaten, um diese zwischen verschiedenen Quellen zu synchronisieren und Konflikte zu lösen.</string>
|
||||||
|
<key>NSHealthUpdateUsageDescription</key>
|
||||||
|
<string>HealthBridge schreibt bereinigte Gesundheitsdaten zurück in Apple Health, um eine konsistente Datenbasis zu gewährleisten.</string>
|
||||||
|
|
||||||
|
<!-- Background Modes -->
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>fetch</string>
|
||||||
|
<string>processing</string>
|
||||||
|
</array>
|
||||||
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>com.healthbridge.sync</string>
|
||||||
|
<string>com.healthbridge.cleanup</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
import Foundation
|
||||||
|
import HealthKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - Data Reader
|
||||||
|
@MainActor
|
||||||
|
class DataReader: ObservableObject {
|
||||||
|
static let shared = DataReader()
|
||||||
|
|
||||||
|
private let healthKitManager = HealthKitManager.shared
|
||||||
|
private let sourceManager = SourceManager.shared
|
||||||
|
|
||||||
|
@Published var isReading = false
|
||||||
|
@Published var lastReadDate: Date?
|
||||||
|
@Published var detectedConflicts: [Conflict] = []
|
||||||
|
@Published var readingProgress: Double = 0
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Fetch Data by Type and Date Range
|
||||||
|
|
||||||
|
func fetchData(
|
||||||
|
for dataType: HealthDataType,
|
||||||
|
from startDate: Date,
|
||||||
|
to endDate: Date,
|
||||||
|
groupByWindow intervalMinutes: Int = 15
|
||||||
|
) async throws -> [TimeWindowData] {
|
||||||
|
isReading = true
|
||||||
|
defer { isReading = false }
|
||||||
|
|
||||||
|
let samples = try await healthKitManager.fetchSamples(
|
||||||
|
for: dataType,
|
||||||
|
from: startDate,
|
||||||
|
to: endDate
|
||||||
|
)
|
||||||
|
|
||||||
|
// Group samples by source
|
||||||
|
let groupedBySource = groupBySource(samples: samples, dataType: dataType)
|
||||||
|
|
||||||
|
// Create time windows
|
||||||
|
let windows = generateTimeWindows(from: startDate, to: endDate, intervalMinutes: intervalMinutes)
|
||||||
|
|
||||||
|
// Assign samples to windows
|
||||||
|
var windowDataList: [TimeWindowData] = []
|
||||||
|
|
||||||
|
for window in windows {
|
||||||
|
let windowData = createWindowData(
|
||||||
|
window: window,
|
||||||
|
dataType: dataType,
|
||||||
|
groupedBySource: groupedBySource
|
||||||
|
)
|
||||||
|
windowDataList.append(windowData)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastReadDate = Date()
|
||||||
|
return windowDataList
|
||||||
|
}
|
||||||
|
|
||||||
|
private func groupBySource(samples: [HKSample], dataType: HealthDataType) -> [String: [HKSample]] {
|
||||||
|
var grouped: [String: [HKSample]] = [:]
|
||||||
|
|
||||||
|
for sample in samples {
|
||||||
|
let sourceId = sample.sourceRevision.source.bundleIdentifier
|
||||||
|
if grouped[sourceId] == nil {
|
||||||
|
grouped[sourceId] = []
|
||||||
|
}
|
||||||
|
grouped[sourceId]?.append(sample)
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped
|
||||||
|
}
|
||||||
|
|
||||||
|
private func generateTimeWindows(from start: Date, to end: Date, intervalMinutes: Int) -> [TimeWindow] {
|
||||||
|
var windows: [TimeWindow] = []
|
||||||
|
var current = start
|
||||||
|
|
||||||
|
while current < end {
|
||||||
|
let windowEnd = min(
|
||||||
|
Calendar.current.date(byAdding: .minute, value: intervalMinutes, to: current)!,
|
||||||
|
end
|
||||||
|
)
|
||||||
|
windows.append(TimeWindow(start: current, end: windowEnd))
|
||||||
|
current = windowEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
return windows
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createWindowData(
|
||||||
|
window: TimeWindow,
|
||||||
|
dataType: HealthDataType,
|
||||||
|
groupedBySource: [String: [HKSample]]
|
||||||
|
) -> TimeWindowData {
|
||||||
|
var readings: [SourceReading] = []
|
||||||
|
|
||||||
|
for (sourceId, samples) in groupedBySource {
|
||||||
|
let windowSamples = samples.filter { sample in
|
||||||
|
sample.startDate < window.end && sample.endDate > window.start
|
||||||
|
}
|
||||||
|
|
||||||
|
if !windowSamples.isEmpty {
|
||||||
|
let reading = createReading(
|
||||||
|
from: windowSamples,
|
||||||
|
sourceId: sourceId,
|
||||||
|
dataType: dataType,
|
||||||
|
window: window
|
||||||
|
)
|
||||||
|
readings.append(reading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasConflict = detectConflict(in: readings, dataType: dataType)
|
||||||
|
|
||||||
|
return TimeWindowData(
|
||||||
|
timeWindow: window,
|
||||||
|
dataType: dataType,
|
||||||
|
readings: readings,
|
||||||
|
hasConflict: hasConflict
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createReading(
|
||||||
|
from samples: [HKSample],
|
||||||
|
sourceId: String,
|
||||||
|
dataType: HealthDataType,
|
||||||
|
window: TimeWindow
|
||||||
|
) -> SourceReading {
|
||||||
|
let value: Double
|
||||||
|
var secondaryValue: Double? = nil
|
||||||
|
|
||||||
|
switch dataType {
|
||||||
|
case .steps, .floorsClimbed, .activeEnergy, .distance:
|
||||||
|
// Sum up values for cumulative types
|
||||||
|
value = samples.compactMap { sample -> Double? in
|
||||||
|
guard let quantitySample = sample as? HKQuantitySample else { return nil }
|
||||||
|
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
|
||||||
|
}.reduce(0, +)
|
||||||
|
|
||||||
|
case .heartRate, .restingHeartRate, .respiratoryRate, .heartRateVariability, .bloodOxygen:
|
||||||
|
// Average for rate-based types
|
||||||
|
let values = samples.compactMap { sample -> Double? in
|
||||||
|
guard let quantitySample = sample as? HKQuantitySample else { return nil }
|
||||||
|
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
|
||||||
|
}
|
||||||
|
value = values.isEmpty ? 0 : values.reduce(0, +) / Double(values.count)
|
||||||
|
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic:
|
||||||
|
// For blood pressure, we need to handle correlations
|
||||||
|
let values = samples.compactMap { sample -> Double? in
|
||||||
|
guard let quantitySample = sample as? HKQuantitySample else { return nil }
|
||||||
|
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
|
||||||
|
}
|
||||||
|
value = values.isEmpty ? 0 : values.reduce(0, +) / Double(values.count)
|
||||||
|
|
||||||
|
case .sleep:
|
||||||
|
// Sum up sleep duration
|
||||||
|
value = samples.reduce(0) { acc, sample in
|
||||||
|
acc + sample.endDate.timeIntervalSince(sample.startDate) / 3600
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let source = sourceManager.sources.first { $0.bundleIdentifier == sourceId }
|
||||||
|
let category = source?.category ?? sourceManager.classifySource(sourceId)
|
||||||
|
|
||||||
|
return SourceReading(
|
||||||
|
sourceId: sourceId,
|
||||||
|
sourceName: source?.name ?? sourceId,
|
||||||
|
sourceCategory: category,
|
||||||
|
value: value,
|
||||||
|
secondaryValue: secondaryValue,
|
||||||
|
timestamp: window.start,
|
||||||
|
originalRecordId: samples.first?.uuid.uuidString,
|
||||||
|
quality: samples.isEmpty ? .missing : .complete
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Detection
|
||||||
|
|
||||||
|
private func detectConflict(in readings: [SourceReading], dataType: HealthDataType) -> Bool {
|
||||||
|
// No conflict if less than 2 readings
|
||||||
|
guard readings.count >= 2 else { return false }
|
||||||
|
|
||||||
|
// Filter out zero values (device wasn't tracking)
|
||||||
|
let nonZeroReadings = readings.filter { $0.value > 0 }
|
||||||
|
guard nonZeroReadings.count >= 2 else { return false }
|
||||||
|
|
||||||
|
// Check if values differ significantly
|
||||||
|
let values = nonZeroReadings.map { $0.value }
|
||||||
|
guard let minVal = values.min(), let maxVal = values.max() else { return false }
|
||||||
|
|
||||||
|
// Threshold varies by data type
|
||||||
|
let threshold = conflictThreshold(for: dataType)
|
||||||
|
|
||||||
|
if minVal == 0 {
|
||||||
|
return maxVal > threshold.absoluteThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
let percentDiff = (maxVal - minVal) / minVal * 100
|
||||||
|
return percentDiff > threshold.percentageThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
private func conflictThreshold(for dataType: HealthDataType) -> ConflictThreshold {
|
||||||
|
switch dataType {
|
||||||
|
case .steps:
|
||||||
|
return ConflictThreshold(percentageThreshold: 10, absoluteThreshold: 100)
|
||||||
|
case .distance:
|
||||||
|
return ConflictThreshold(percentageThreshold: 10, absoluteThreshold: 0.1) // 100m
|
||||||
|
case .heartRate:
|
||||||
|
return ConflictThreshold(percentageThreshold: 15, absoluteThreshold: 10)
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic:
|
||||||
|
return ConflictThreshold(percentageThreshold: 5, absoluteThreshold: 5)
|
||||||
|
case .bloodOxygen:
|
||||||
|
return ConflictThreshold(percentageThreshold: 2, absoluteThreshold: 2)
|
||||||
|
case .floorsClimbed:
|
||||||
|
return ConflictThreshold(percentageThreshold: 20, absoluteThreshold: 2)
|
||||||
|
case .activeEnergy:
|
||||||
|
return ConflictThreshold(percentageThreshold: 15, absoluteThreshold: 50)
|
||||||
|
case .sleep:
|
||||||
|
return ConflictThreshold(percentageThreshold: 10, absoluteThreshold: 0.5) // 30 min
|
||||||
|
case .restingHeartRate:
|
||||||
|
return ConflictThreshold(percentageThreshold: 10, absoluteThreshold: 5)
|
||||||
|
case .heartRateVariability:
|
||||||
|
return ConflictThreshold(percentageThreshold: 20, absoluteThreshold: 10)
|
||||||
|
case .respiratoryRate:
|
||||||
|
return ConflictThreshold(percentageThreshold: 15, absoluteThreshold: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Detect All Conflicts
|
||||||
|
|
||||||
|
func detectConflicts(
|
||||||
|
for date: Date,
|
||||||
|
dataTypes: [HealthDataType] = HealthDataType.allCases
|
||||||
|
) async throws -> [Conflict] {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let startOfDay = calendar.startOfDay(for: date)
|
||||||
|
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
|
||||||
|
|
||||||
|
var allConflicts: [Conflict] = []
|
||||||
|
|
||||||
|
for (index, dataType) in dataTypes.enumerated() {
|
||||||
|
readingProgress = Double(index) / Double(dataTypes.count)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let windowData = try await fetchData(
|
||||||
|
for: dataType,
|
||||||
|
from: startOfDay,
|
||||||
|
to: endOfDay
|
||||||
|
)
|
||||||
|
|
||||||
|
let conflicts = windowData
|
||||||
|
.filter { $0.hasConflict }
|
||||||
|
.map { data in
|
||||||
|
Conflict(
|
||||||
|
dataType: dataType,
|
||||||
|
timeWindow: data.timeWindow,
|
||||||
|
readings: data.readings,
|
||||||
|
status: .pending
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
allConflicts.append(contentsOf: conflicts)
|
||||||
|
} catch {
|
||||||
|
print("Failed to detect conflicts for \(dataType): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readingProgress = 1.0
|
||||||
|
detectedConflicts = allConflicts
|
||||||
|
return allConflicts
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Gaps Detection
|
||||||
|
|
||||||
|
func detectGaps(
|
||||||
|
for dataType: HealthDataType,
|
||||||
|
from startDate: Date,
|
||||||
|
to endDate: Date,
|
||||||
|
expectedIntervalMinutes: Int = 15
|
||||||
|
) async throws -> [DataGap] {
|
||||||
|
let samples = try await healthKitManager.fetchSamples(
|
||||||
|
for: dataType,
|
||||||
|
from: startDate,
|
||||||
|
to: endDate
|
||||||
|
)
|
||||||
|
|
||||||
|
guard !samples.isEmpty else {
|
||||||
|
return [DataGap(
|
||||||
|
dataType: dataType,
|
||||||
|
timeWindow: TimeWindow(start: startDate, end: endDate),
|
||||||
|
expectedRecordCount: 0,
|
||||||
|
actualRecordCount: 0
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
let sortedSamples = samples.sorted { $0.startDate < $1.startDate }
|
||||||
|
var gaps: [DataGap] = []
|
||||||
|
let expectedInterval = TimeInterval(expectedIntervalMinutes * 60)
|
||||||
|
|
||||||
|
// Check gap at start
|
||||||
|
if let firstSample = sortedSamples.first,
|
||||||
|
firstSample.startDate.timeIntervalSince(startDate) > expectedInterval * 2 {
|
||||||
|
gaps.append(DataGap(
|
||||||
|
dataType: dataType,
|
||||||
|
timeWindow: TimeWindow(start: startDate, end: firstSample.startDate),
|
||||||
|
expectedRecordCount: Int(firstSample.startDate.timeIntervalSince(startDate) / expectedInterval),
|
||||||
|
actualRecordCount: 0
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check gaps between samples
|
||||||
|
for i in 0..<(sortedSamples.count - 1) {
|
||||||
|
let current = sortedSamples[i]
|
||||||
|
let next = sortedSamples[i + 1]
|
||||||
|
let gap = next.startDate.timeIntervalSince(current.endDate)
|
||||||
|
|
||||||
|
if gap > expectedInterval * 2 {
|
||||||
|
gaps.append(DataGap(
|
||||||
|
dataType: dataType,
|
||||||
|
timeWindow: TimeWindow(start: current.endDate, end: next.startDate),
|
||||||
|
expectedRecordCount: Int(gap / expectedInterval),
|
||||||
|
actualRecordCount: 0
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check gap at end
|
||||||
|
if let lastSample = sortedSamples.last,
|
||||||
|
endDate.timeIntervalSince(lastSample.endDate) > expectedInterval * 2 {
|
||||||
|
gaps.append(DataGap(
|
||||||
|
dataType: dataType,
|
||||||
|
timeWindow: TimeWindow(start: lastSample.endDate, end: endDate),
|
||||||
|
expectedRecordCount: Int(endDate.timeIntervalSince(lastSample.endDate) / expectedInterval),
|
||||||
|
actualRecordCount: 0
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return gaps
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Aggregated Data
|
||||||
|
|
||||||
|
func fetchDailySummary(for date: Date) async throws -> DailySummary {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let startOfDay = calendar.startOfDay(for: date)
|
||||||
|
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
|
||||||
|
|
||||||
|
var summary = DailySummary(date: date)
|
||||||
|
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
do {
|
||||||
|
let samples = try await healthKitManager.fetchSamples(
|
||||||
|
for: dataType,
|
||||||
|
from: startOfDay,
|
||||||
|
to: endOfDay
|
||||||
|
)
|
||||||
|
|
||||||
|
let value = aggregateValue(samples: samples, dataType: dataType)
|
||||||
|
summary.values[dataType] = value
|
||||||
|
|
||||||
|
// Check for conflicts
|
||||||
|
let windowData = try await fetchData(
|
||||||
|
for: dataType,
|
||||||
|
from: startOfDay,
|
||||||
|
to: endOfDay
|
||||||
|
)
|
||||||
|
let conflictCount = windowData.filter { $0.hasConflict }.count
|
||||||
|
summary.conflictCounts[dataType] = conflictCount
|
||||||
|
} catch {
|
||||||
|
print("Failed to fetch \(dataType) for summary: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
private func aggregateValue(samples: [HKSample], dataType: HealthDataType) -> Double {
|
||||||
|
switch dataType {
|
||||||
|
case .steps, .floorsClimbed, .activeEnergy, .distance:
|
||||||
|
return samples.compactMap { sample -> Double? in
|
||||||
|
guard let quantitySample = sample as? HKQuantitySample else { return nil }
|
||||||
|
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
|
||||||
|
}.reduce(0, +)
|
||||||
|
|
||||||
|
case .heartRate, .restingHeartRate, .respiratoryRate, .heartRateVariability,
|
||||||
|
.bloodOxygen, .bloodPressureSystolic, .bloodPressureDiastolic:
|
||||||
|
let values = samples.compactMap { sample -> Double? in
|
||||||
|
guard let quantitySample = sample as? HKQuantitySample else { return nil }
|
||||||
|
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
|
||||||
|
}
|
||||||
|
return values.isEmpty ? 0 : values.reduce(0, +) / Double(values.count)
|
||||||
|
|
||||||
|
case .sleep:
|
||||||
|
return samples.reduce(0) { acc, sample in
|
||||||
|
acc + sample.endDate.timeIntervalSince(sample.startDate) / 3600
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting Types
|
||||||
|
|
||||||
|
struct TimeWindowData: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let timeWindow: TimeWindow
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let readings: [SourceReading]
|
||||||
|
let hasConflict: Bool
|
||||||
|
|
||||||
|
var primaryReading: SourceReading? {
|
||||||
|
readings.max { $0.sourceCategory.priority < $1.sourceCategory.priority }
|
||||||
|
}
|
||||||
|
|
||||||
|
var conflictSeverity: ConflictSeverity? {
|
||||||
|
guard hasConflict, readings.count >= 2 else { return nil }
|
||||||
|
|
||||||
|
let values = readings.map { $0.value }.filter { $0 > 0 }
|
||||||
|
guard let min = values.min(), let max = values.max(), min > 0 else { return nil }
|
||||||
|
|
||||||
|
let percentDiff = (max - min) / min * 100
|
||||||
|
|
||||||
|
if percentDiff < 5 { return .minor }
|
||||||
|
if percentDiff < 20 { return .moderate }
|
||||||
|
if percentDiff < 50 { return .significant }
|
||||||
|
return .major
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ConflictThreshold {
|
||||||
|
let percentageThreshold: Double
|
||||||
|
let absoluteThreshold: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DataGap: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let timeWindow: TimeWindow
|
||||||
|
let expectedRecordCount: Int
|
||||||
|
let actualRecordCount: Int
|
||||||
|
|
||||||
|
var severity: GapSeverity {
|
||||||
|
let duration = timeWindow.duration
|
||||||
|
if duration < 3600 { return .minor } // < 1 hour
|
||||||
|
if duration < 4 * 3600 { return .moderate } // < 4 hours
|
||||||
|
if duration < 12 * 3600 { return .significant } // < 12 hours
|
||||||
|
return .major
|
||||||
|
}
|
||||||
|
|
||||||
|
enum GapSeverity {
|
||||||
|
case minor, moderate, significant, major
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DailySummary {
|
||||||
|
let date: Date
|
||||||
|
var values: [HealthDataType: Double] = [:]
|
||||||
|
var conflictCounts: [HealthDataType: Int] = [:]
|
||||||
|
var lastUpdated = Date()
|
||||||
|
|
||||||
|
var totalConflicts: Int {
|
||||||
|
conflictCounts.values.reduce(0, +)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formattedValue(for dataType: HealthDataType) -> String {
|
||||||
|
guard let value = values[dataType] else { return "–" }
|
||||||
|
|
||||||
|
switch dataType {
|
||||||
|
case .steps, .floorsClimbed:
|
||||||
|
return String(format: "%.0f", value)
|
||||||
|
case .distance:
|
||||||
|
return String(format: "%.2f km", value)
|
||||||
|
case .heartRate, .restingHeartRate, .respiratoryRate:
|
||||||
|
return String(format: "%.0f %@", value, dataType.unit)
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic:
|
||||||
|
return String(format: "%.0f mmHg", value)
|
||||||
|
case .bloodOxygen:
|
||||||
|
return String(format: "%.0f%%", value * 100)
|
||||||
|
case .activeEnergy:
|
||||||
|
return String(format: "%.0f kcal", value)
|
||||||
|
case .sleep:
|
||||||
|
let hours = Int(value)
|
||||||
|
let minutes = Int((value - Double(hours)) * 60)
|
||||||
|
return "\(hours)h \(minutes)min"
|
||||||
|
case .heartRateVariability:
|
||||||
|
return String(format: "%.0f ms", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
import Foundation
|
||||||
|
import HealthKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - Data Writer
|
||||||
|
@MainActor
|
||||||
|
class DataWriter: ObservableObject {
|
||||||
|
static let shared = DataWriter()
|
||||||
|
|
||||||
|
private let healthKitManager = HealthKitManager.shared
|
||||||
|
private let healthStore = HKHealthStore()
|
||||||
|
|
||||||
|
@Published var isWriting = false
|
||||||
|
@Published var writeProgress: Double = 0
|
||||||
|
@Published var lastWriteDate: Date?
|
||||||
|
@Published var writtenRecords: [WrittenRecord] = []
|
||||||
|
@Published var failedWrites: [FailedWrite] = []
|
||||||
|
|
||||||
|
private let processedRecordsKey = "healthbridge.processed.records"
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
loadProcessedRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Write Single Record
|
||||||
|
|
||||||
|
func writeRecord(_ mergedRecord: MergedRecord) async throws -> WrittenRecord {
|
||||||
|
isWriting = true
|
||||||
|
defer { isWriting = false }
|
||||||
|
|
||||||
|
// Check if already written
|
||||||
|
if isAlreadyWritten(mergedRecord) {
|
||||||
|
throw DataWriterError.duplicateRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata = createMetadata(from: mergedRecord)
|
||||||
|
|
||||||
|
switch mergedRecord.dataType {
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic:
|
||||||
|
// Blood pressure needs special handling
|
||||||
|
guard let diastolic = mergedRecord.secondaryValue else {
|
||||||
|
throw DataWriterError.missingSecondaryValue
|
||||||
|
}
|
||||||
|
try await writeBloodPressure(
|
||||||
|
systolic: mergedRecord.value,
|
||||||
|
diastolic: diastolic,
|
||||||
|
date: mergedRecord.timeWindow.start,
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
try await writeSample(
|
||||||
|
dataType: mergedRecord.dataType,
|
||||||
|
value: mergedRecord.value,
|
||||||
|
date: mergedRecord.timeWindow.start,
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let writtenRecord = WrittenRecord(
|
||||||
|
id: UUID(),
|
||||||
|
mergedRecordId: mergedRecord.id,
|
||||||
|
dataType: mergedRecord.dataType,
|
||||||
|
value: mergedRecord.value,
|
||||||
|
secondaryValue: mergedRecord.secondaryValue,
|
||||||
|
writtenAt: Date(),
|
||||||
|
timeWindow: mergedRecord.timeWindow
|
||||||
|
)
|
||||||
|
|
||||||
|
writtenRecords.append(writtenRecord)
|
||||||
|
markAsProcessed(mergedRecord)
|
||||||
|
lastWriteDate = Date()
|
||||||
|
|
||||||
|
return writtenRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Write Batch
|
||||||
|
|
||||||
|
func writeBatch(_ mergedRecords: [MergedRecord]) async -> BatchWriteResult {
|
||||||
|
isWriting = true
|
||||||
|
defer { isWriting = false }
|
||||||
|
|
||||||
|
var successful: [WrittenRecord] = []
|
||||||
|
var failed: [FailedWrite] = []
|
||||||
|
|
||||||
|
for (index, record) in mergedRecords.enumerated() {
|
||||||
|
writeProgress = Double(index) / Double(mergedRecords.count)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let writtenRecord = try await writeRecord(record)
|
||||||
|
successful.append(writtenRecord)
|
||||||
|
} catch {
|
||||||
|
let failedWrite = FailedWrite(
|
||||||
|
mergedRecord: record,
|
||||||
|
error: error,
|
||||||
|
attemptedAt: Date()
|
||||||
|
)
|
||||||
|
failed.append(failedWrite)
|
||||||
|
failedWrites.append(failedWrite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeProgress = 1.0
|
||||||
|
|
||||||
|
return BatchWriteResult(
|
||||||
|
successful: successful,
|
||||||
|
failed: failed,
|
||||||
|
completedAt: Date()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Write Methods
|
||||||
|
|
||||||
|
private func writeSample(
|
||||||
|
dataType: HealthDataType,
|
||||||
|
value: Double,
|
||||||
|
date: Date,
|
||||||
|
metadata: [String: Any]
|
||||||
|
) async throws {
|
||||||
|
guard let quantityType = dataType.hkQuantityType else {
|
||||||
|
throw DataWriterError.unsupportedDataType
|
||||||
|
}
|
||||||
|
|
||||||
|
let quantity = HKQuantity(unit: dataType.hkUnit, doubleValue: value)
|
||||||
|
let sample = HKQuantitySample(
|
||||||
|
type: quantityType,
|
||||||
|
quantity: quantity,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
try await healthStore.save(sample)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func writeBloodPressure(
|
||||||
|
systolic: Double,
|
||||||
|
diastolic: Double,
|
||||||
|
date: Date,
|
||||||
|
metadata: [String: Any]
|
||||||
|
) async throws {
|
||||||
|
// Validate blood pressure values
|
||||||
|
let validation = BloodPressureHandler.shared.validate(
|
||||||
|
systolic: systolic,
|
||||||
|
diastolic: diastolic
|
||||||
|
)
|
||||||
|
|
||||||
|
if !validation.isValid {
|
||||||
|
throw DataWriterError.invalidValue(validation.issues.joined(separator: ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let systolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureSystolic),
|
||||||
|
let diastolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureDiastolic),
|
||||||
|
let correlationType = HKCorrelationType.correlationType(forIdentifier: .bloodPressure) else {
|
||||||
|
throw DataWriterError.unsupportedDataType
|
||||||
|
}
|
||||||
|
|
||||||
|
let systolicQuantity = HKQuantity(unit: .millimeterOfMercury(), doubleValue: systolic)
|
||||||
|
let diastolicQuantity = HKQuantity(unit: .millimeterOfMercury(), doubleValue: diastolic)
|
||||||
|
|
||||||
|
let systolicSample = HKQuantitySample(
|
||||||
|
type: systolicType,
|
||||||
|
quantity: systolicQuantity,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
let diastolicSample = HKQuantitySample(
|
||||||
|
type: diastolicType,
|
||||||
|
quantity: diastolicQuantity,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
let correlation = HKCorrelation(
|
||||||
|
type: correlationType,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
objects: [systolicSample, diastolicSample],
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
try await healthStore.save(correlation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Metadata
|
||||||
|
|
||||||
|
private func createMetadata(from record: MergedRecord) -> [String: Any] {
|
||||||
|
var metadata: [String: Any] = [
|
||||||
|
HKMetadataKeyWasUserEntered: false,
|
||||||
|
"HealthBridgeSource": HealthBridgeConstants.bundleIdentifier,
|
||||||
|
"OriginalSourceId": record.originalSourceId,
|
||||||
|
"MergeStrategy": record.strategy.rawValue,
|
||||||
|
"MergedRecordId": record.id.uuidString,
|
||||||
|
"MergedAt": ISO8601DateFormatter().string(from: record.createdAt)
|
||||||
|
]
|
||||||
|
|
||||||
|
for (key, value) in record.metadata {
|
||||||
|
metadata["HB_\(key)"] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Duplicate Prevention
|
||||||
|
|
||||||
|
private var processedRecordIds: Set<String> = []
|
||||||
|
|
||||||
|
private func loadProcessedRecords() {
|
||||||
|
if let data = UserDefaults.standard.data(forKey: processedRecordsKey),
|
||||||
|
let ids = try? JSONDecoder().decode(Set<String>.self, from: data) {
|
||||||
|
processedRecordIds = ids
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveProcessedRecords() {
|
||||||
|
if let data = try? JSONEncoder().encode(processedRecordIds) {
|
||||||
|
UserDefaults.standard.set(data, forKey: processedRecordsKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isAlreadyWritten(_ record: MergedRecord) -> Bool {
|
||||||
|
let identifier = createRecordIdentifier(record)
|
||||||
|
return processedRecordIds.contains(identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func markAsProcessed(_ record: MergedRecord) {
|
||||||
|
let identifier = createRecordIdentifier(record)
|
||||||
|
processedRecordIds.insert(identifier)
|
||||||
|
saveProcessedRecords()
|
||||||
|
|
||||||
|
// Cleanup old records (keep last 7 days)
|
||||||
|
cleanupOldRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createRecordIdentifier(_ record: MergedRecord) -> String {
|
||||||
|
let components = [
|
||||||
|
record.dataType.rawValue,
|
||||||
|
String(record.timeWindow.start.timeIntervalSince1970),
|
||||||
|
String(record.value)
|
||||||
|
]
|
||||||
|
return components.joined(separator: "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cleanupOldRecords() {
|
||||||
|
// Keep only identifiers that contain recent timestamps
|
||||||
|
let sevenDaysAgo = Date().addingTimeInterval(-7 * 24 * 60 * 60)
|
||||||
|
let cutoffTimestamp = sevenDaysAgo.timeIntervalSince1970
|
||||||
|
|
||||||
|
processedRecordIds = processedRecordIds.filter { identifier in
|
||||||
|
guard let parts = identifier.split(separator: "-").dropFirst().first,
|
||||||
|
let timestamp = Double(parts) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return timestamp > cutoffTimestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
saveProcessedRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Delete Records
|
||||||
|
|
||||||
|
func deleteHealthBridgeRecords(
|
||||||
|
for dataType: HealthDataType,
|
||||||
|
from startDate: Date,
|
||||||
|
to endDate: Date
|
||||||
|
) async throws -> Int {
|
||||||
|
guard let sampleType = dataType.hkQuantityType else {
|
||||||
|
throw DataWriterError.unsupportedDataType
|
||||||
|
}
|
||||||
|
|
||||||
|
let predicate = HKQuery.predicateForSamples(
|
||||||
|
withStart: startDate,
|
||||||
|
end: endDate,
|
||||||
|
options: .strictStartDate
|
||||||
|
)
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
let query = HKSampleQuery(
|
||||||
|
sampleType: sampleType,
|
||||||
|
predicate: predicate,
|
||||||
|
limit: HKObjectQueryNoLimit,
|
||||||
|
sortDescriptors: nil
|
||||||
|
) { [weak self] _, samplesOrNil, errorOrNil in
|
||||||
|
guard let self = self else {
|
||||||
|
continuation.resume(throwing: DataWriterError.unknownError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = errorOrNil {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let samples = samplesOrNil else {
|
||||||
|
continuation.resume(returning: 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to only HealthBridge records
|
||||||
|
let healthBridgeSamples = samples.filter { sample in
|
||||||
|
if let metadata = sample.metadata,
|
||||||
|
let source = metadata["HealthBridgeSource"] as? String {
|
||||||
|
return source == HealthBridgeConstants.bundleIdentifier
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !healthBridgeSamples.isEmpty else {
|
||||||
|
continuation.resume(returning: 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await self.healthStore.delete(healthBridgeSamples)
|
||||||
|
continuation.resume(returning: healthBridgeSamples.count)
|
||||||
|
} catch {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
healthStore.execute(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting Types
|
||||||
|
|
||||||
|
struct WrittenRecord: Identifiable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let mergedRecordId: UUID
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let value: Double
|
||||||
|
let secondaryValue: Double?
|
||||||
|
let writtenAt: Date
|
||||||
|
let timeWindow: TimeWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FailedWrite: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let mergedRecord: MergedRecord
|
||||||
|
let error: Error
|
||||||
|
let attemptedAt: Date
|
||||||
|
|
||||||
|
var errorMessage: String {
|
||||||
|
error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BatchWriteResult {
|
||||||
|
let successful: [WrittenRecord]
|
||||||
|
let failed: [FailedWrite]
|
||||||
|
let completedAt: Date
|
||||||
|
|
||||||
|
var successCount: Int { successful.count }
|
||||||
|
var failureCount: Int { failed.count }
|
||||||
|
var totalCount: Int { successCount + failureCount }
|
||||||
|
|
||||||
|
var successRate: Double {
|
||||||
|
guard totalCount > 0 else { return 1.0 }
|
||||||
|
return Double(successCount) / Double(totalCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
|
||||||
|
enum DataWriterError: LocalizedError {
|
||||||
|
case unsupportedDataType
|
||||||
|
case duplicateRecord
|
||||||
|
case missingSecondaryValue
|
||||||
|
case invalidValue(String)
|
||||||
|
case writeFailed(String)
|
||||||
|
case unknownError
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .unsupportedDataType:
|
||||||
|
return "Dieser Datentyp wird nicht unterstützt"
|
||||||
|
case .duplicateRecord:
|
||||||
|
return "Dieser Datensatz wurde bereits geschrieben"
|
||||||
|
case .missingSecondaryValue:
|
||||||
|
return "Fehlender sekundärer Wert (z.B. diastolischer Blutdruck)"
|
||||||
|
case .invalidValue(let message):
|
||||||
|
return "Ungültiger Wert: \(message)"
|
||||||
|
case .writeFailed(let message):
|
||||||
|
return "Schreiben fehlgeschlagen: \(message)"
|
||||||
|
case .unknownError:
|
||||||
|
return "Unbekannter Fehler"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
import Foundation
|
||||||
|
import HealthKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - HealthKit Manager
|
||||||
|
@MainActor
|
||||||
|
class HealthKitManager: ObservableObject {
|
||||||
|
static let shared = HealthKitManager()
|
||||||
|
|
||||||
|
private let healthStore = HKHealthStore()
|
||||||
|
|
||||||
|
@Published var isAuthorized = false
|
||||||
|
@Published var authorizationStatus: [HealthDataType: HKAuthorizationStatus] = [:]
|
||||||
|
@Published var discoveredSources: [HealthSource] = []
|
||||||
|
@Published var sourceHealthStatus: [String: SourceHealthStatus] = [:]
|
||||||
|
@Published var lastError: Error?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Authorization
|
||||||
|
|
||||||
|
var allQuantityTypes: Set<HKQuantityType> {
|
||||||
|
var types = Set<HKQuantityType>()
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
if let quantityType = dataType.hkQuantityType {
|
||||||
|
types.insert(quantityType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
var allCategoryTypes: Set<HKCategoryType> {
|
||||||
|
var types = Set<HKCategoryType>()
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
if let categoryType = dataType.hkCategoryType {
|
||||||
|
types.insert(categoryType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
var allSampleTypes: Set<HKSampleType> {
|
||||||
|
var types = Set<HKSampleType>()
|
||||||
|
allQuantityTypes.forEach { types.insert($0) }
|
||||||
|
allCategoryTypes.forEach { types.insert($0) }
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestAuthorization() async throws {
|
||||||
|
guard HKHealthStore.isHealthDataAvailable() else {
|
||||||
|
throw HealthKitError.healthDataNotAvailable
|
||||||
|
}
|
||||||
|
|
||||||
|
let typesToRead = allSampleTypes
|
||||||
|
let typesToWrite = allQuantityTypes
|
||||||
|
|
||||||
|
try await healthStore.requestAuthorization(toShare: typesToWrite, read: typesToRead)
|
||||||
|
isAuthorized = true
|
||||||
|
|
||||||
|
await updateAuthorizationStatus()
|
||||||
|
await discoverSources()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAuthorizationStatus() async {
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
if let quantityType = dataType.hkQuantityType {
|
||||||
|
let status = healthStore.authorizationStatus(for: quantityType)
|
||||||
|
authorizationStatus[dataType] = status
|
||||||
|
} else if let categoryType = dataType.hkCategoryType {
|
||||||
|
let status = healthStore.authorizationStatus(for: categoryType)
|
||||||
|
authorizationStatus[dataType] = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Discovery
|
||||||
|
|
||||||
|
func discoverSources() async {
|
||||||
|
var allSources: [String: HealthSource] = [:]
|
||||||
|
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
guard let sampleType = dataType.hkQuantityType ?? dataType.hkCategoryType else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let sources = try await fetchSources(for: sampleType)
|
||||||
|
for source in sources {
|
||||||
|
if var existingSource = allSources[source.bundleIdentifier] {
|
||||||
|
existingSource.supportedDataTypes.insert(dataType)
|
||||||
|
allSources[source.bundleIdentifier] = existingSource
|
||||||
|
} else {
|
||||||
|
var newSource = source
|
||||||
|
newSource.supportedDataTypes.insert(dataType)
|
||||||
|
allSources[source.bundleIdentifier] = newSource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Failed to fetch sources for \(dataType): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
discoveredSources = Array(allSources.values).sorted { $0.category.priority > $1.category.priority }
|
||||||
|
|
||||||
|
// Update source health status
|
||||||
|
for source in discoveredSources {
|
||||||
|
await updateSourceHealth(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchSources(for sampleType: HKSampleType) async throws -> [HealthSource] {
|
||||||
|
let query = HKSourceQuery(sampleType: sampleType, samplePredicate: nil) { _, sourcesOrNil, errorOrNil in
|
||||||
|
// Handled via continuation
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
let query = HKSourceQuery(sampleType: sampleType, samplePredicate: nil) { _, sourcesOrNil, errorOrNil in
|
||||||
|
if let error = errorOrNil {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let sources = sourcesOrNil else {
|
||||||
|
continuation.resume(returning: [])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let healthSources = sources.map { HealthSource.from(hkSource: $0) }
|
||||||
|
continuation.resume(returning: healthSources)
|
||||||
|
}
|
||||||
|
|
||||||
|
healthStore.execute(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateSourceHealth(_ source: HealthSource) async {
|
||||||
|
var recordCount = 0
|
||||||
|
var lastActivity: Date?
|
||||||
|
|
||||||
|
for dataType in source.supportedDataTypes {
|
||||||
|
if let quantityType = dataType.hkQuantityType {
|
||||||
|
let predicate = HKQuery.predicateForObjects(from: HKSource(bundleIdentifier: source.bundleIdentifier, name: source.name) )
|
||||||
|
// Simplified: just get count
|
||||||
|
if let count = try? await fetchRecordCount(for: quantityType, source: source) {
|
||||||
|
recordCount += count
|
||||||
|
}
|
||||||
|
if let date = try? await fetchLastActivityDate(for: quantityType, source: source) {
|
||||||
|
if lastActivity == nil || date > lastActivity! {
|
||||||
|
lastActivity = date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = SourceHealthStatus(
|
||||||
|
id: source.id,
|
||||||
|
source: source,
|
||||||
|
lastSync: lastActivity,
|
||||||
|
recordCount: recordCount,
|
||||||
|
dataGaps: [], // TODO: Implement gap detection
|
||||||
|
overallQuality: recordCount > 0 ? .complete : .missing
|
||||||
|
)
|
||||||
|
|
||||||
|
sourceHealthStatus[source.id] = status
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchRecordCount(for sampleType: HKSampleType, source: HealthSource) async throws -> Int {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let now = Date()
|
||||||
|
let startOfDay = calendar.startOfDay(for: now)
|
||||||
|
|
||||||
|
let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: now, options: .strictStartDate)
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
let query = HKSampleQuery(
|
||||||
|
sampleType: sampleType,
|
||||||
|
predicate: predicate,
|
||||||
|
limit: HKObjectQueryNoLimit,
|
||||||
|
sortDescriptors: nil
|
||||||
|
) { _, samplesOrNil, errorOrNil in
|
||||||
|
if let error = errorOrNil {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let samples = samplesOrNil ?? []
|
||||||
|
let matchingSamples = samples.filter { $0.sourceRevision.source.bundleIdentifier == source.bundleIdentifier }
|
||||||
|
continuation.resume(returning: matchingSamples.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.healthStore.execute(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchLastActivityDate(for sampleType: HKSampleType, source: HealthSource) async throws -> Date? {
|
||||||
|
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
let query = HKSampleQuery(
|
||||||
|
sampleType: sampleType,
|
||||||
|
predicate: nil,
|
||||||
|
limit: 1,
|
||||||
|
sortDescriptors: [sortDescriptor]
|
||||||
|
) { _, samplesOrNil, errorOrNil in
|
||||||
|
if let error = errorOrNil {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let matchingSample = samplesOrNil?.first { $0.sourceRevision.source.bundleIdentifier == source.bundleIdentifier }
|
||||||
|
continuation.resume(returning: matchingSample?.endDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.healthStore.execute(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Classification
|
||||||
|
|
||||||
|
func classifySource(_ source: HKSource) -> SourceCategory {
|
||||||
|
let bundleId = source.bundleIdentifier.lowercased()
|
||||||
|
|
||||||
|
if bundleId.contains("healthbridge") {
|
||||||
|
return .healthBridge
|
||||||
|
} else if bundleId.contains("apple.health") && !bundleId.contains("watch") {
|
||||||
|
return .iPhone
|
||||||
|
} else if bundleId.contains("apple") && bundleId.contains("watch") {
|
||||||
|
return .watch
|
||||||
|
} else if bundleId.contains("huawei") {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
} else if bundleId.contains("samsung") || bundleId.contains("galaxy") {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
} else if bundleId.contains("fitbit") {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
} else if bundleId.contains("garmin") {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
} else if bundleId.contains("polar") {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
} else if bundleId.contains("withings") {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
} else {
|
||||||
|
return .thirdPartyApp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSourceCapabilities(_ source: HealthSource) -> Set<HealthDataType> {
|
||||||
|
return source.supportedDataTypes
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Fetching (Basic)
|
||||||
|
|
||||||
|
func fetchSamples(
|
||||||
|
for dataType: HealthDataType,
|
||||||
|
from startDate: Date,
|
||||||
|
to endDate: Date
|
||||||
|
) async throws -> [HKSample] {
|
||||||
|
guard let sampleType = dataType.hkQuantityType ?? dataType.hkCategoryType else {
|
||||||
|
throw HealthKitError.unsupportedDataType
|
||||||
|
}
|
||||||
|
|
||||||
|
let predicate = HKQuery.predicateForSamples(
|
||||||
|
withStart: startDate,
|
||||||
|
end: endDate,
|
||||||
|
options: .strictStartDate
|
||||||
|
)
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
let query = HKSampleQuery(
|
||||||
|
sampleType: sampleType,
|
||||||
|
predicate: predicate,
|
||||||
|
limit: HKObjectQueryNoLimit,
|
||||||
|
sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)]
|
||||||
|
) { _, samplesOrNil, errorOrNil in
|
||||||
|
if let error = errorOrNil {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continuation.resume(returning: samplesOrNil ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
|
self.healthStore.execute(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Writing
|
||||||
|
|
||||||
|
func writeSample(
|
||||||
|
dataType: HealthDataType,
|
||||||
|
value: Double,
|
||||||
|
secondaryValue: Double? = nil,
|
||||||
|
date: Date,
|
||||||
|
metadata: [String: Any]? = nil
|
||||||
|
) async throws {
|
||||||
|
guard let quantityType = dataType.hkQuantityType else {
|
||||||
|
throw HealthKitError.unsupportedDataType
|
||||||
|
}
|
||||||
|
|
||||||
|
let quantity = HKQuantity(unit: dataType.hkUnit, doubleValue: value)
|
||||||
|
let sample = HKQuantitySample(
|
||||||
|
type: quantityType,
|
||||||
|
quantity: quantity,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
try await healthStore.save(sample)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeBloodPressure(
|
||||||
|
systolic: Double,
|
||||||
|
diastolic: Double,
|
||||||
|
date: Date,
|
||||||
|
metadata: [String: Any]? = nil
|
||||||
|
) async throws {
|
||||||
|
guard let systolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureSystolic),
|
||||||
|
let diastolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureDiastolic) else {
|
||||||
|
throw HealthKitError.unsupportedDataType
|
||||||
|
}
|
||||||
|
|
||||||
|
let systolicQuantity = HKQuantity(unit: .millimeterOfMercury(), doubleValue: systolic)
|
||||||
|
let diastolicQuantity = HKQuantity(unit: .millimeterOfMercury(), doubleValue: diastolic)
|
||||||
|
|
||||||
|
let systolicSample = HKQuantitySample(
|
||||||
|
type: systolicType,
|
||||||
|
quantity: systolicQuantity,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
let diastolicSample = HKQuantitySample(
|
||||||
|
type: diastolicType,
|
||||||
|
quantity: diastolicQuantity,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create correlation for blood pressure
|
||||||
|
guard let correlationType = HKCorrelationType.correlationType(forIdentifier: .bloodPressure) else {
|
||||||
|
throw HealthKitError.unsupportedDataType
|
||||||
|
}
|
||||||
|
|
||||||
|
let correlation = HKCorrelation(
|
||||||
|
type: correlationType,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
objects: [systolicSample, diastolicSample],
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
try await healthStore.save(correlation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HealthKit Errors
|
||||||
|
enum HealthKitError: LocalizedError {
|
||||||
|
case healthDataNotAvailable
|
||||||
|
case authorizationDenied
|
||||||
|
case unsupportedDataType
|
||||||
|
case noDataFound
|
||||||
|
case writeFailed(Error)
|
||||||
|
case queryFailed(Error)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .healthDataNotAvailable:
|
||||||
|
return "Health-Daten sind auf diesem Gerät nicht verfügbar"
|
||||||
|
case .authorizationDenied:
|
||||||
|
return "Zugriff auf Health-Daten wurde verweigert"
|
||||||
|
case .unsupportedDataType:
|
||||||
|
return "Dieser Datentyp wird nicht unterstützt"
|
||||||
|
case .noDataFound:
|
||||||
|
return "Keine Daten gefunden"
|
||||||
|
case .writeFailed(let error):
|
||||||
|
return "Schreiben fehlgeschlagen: \(error.localizedDescription)"
|
||||||
|
case .queryFailed(let error):
|
||||||
|
return "Abfrage fehlgeschlagen: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HKSource Extension
|
||||||
|
extension HKSource {
|
||||||
|
convenience init(bundleIdentifier: String, name: String) {
|
||||||
|
// Note: This is a workaround since HKSource doesn't have a public initializer
|
||||||
|
// In production, sources come from HealthKit queries
|
||||||
|
fatalError("HKSource cannot be initialized directly - use source from HKSample")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - Merge Engine
|
||||||
|
@MainActor
|
||||||
|
class MergeEngine: ObservableObject {
|
||||||
|
static let shared = MergeEngine()
|
||||||
|
|
||||||
|
private let ruleEngine = RuleEngine.shared
|
||||||
|
private let dataReader = DataReader.shared
|
||||||
|
|
||||||
|
@Published var pendingMerges: [MergeOperation] = []
|
||||||
|
@Published var completedMerges: [MergeOperation] = []
|
||||||
|
@Published var isMerging = false
|
||||||
|
@Published var mergeProgress: Double = 0
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Analyze Window
|
||||||
|
|
||||||
|
func analyze(windowData: TimeWindowData) -> WindowAnalysis {
|
||||||
|
let readings = windowData.readings
|
||||||
|
let dataType = windowData.dataType
|
||||||
|
|
||||||
|
// No analysis needed for single reading
|
||||||
|
if readings.count <= 1 {
|
||||||
|
return WindowAnalysis(
|
||||||
|
windowData: windowData,
|
||||||
|
hasConflict: false,
|
||||||
|
conflictSeverity: nil,
|
||||||
|
recommendedReading: readings.first,
|
||||||
|
alternativeReadings: [],
|
||||||
|
confidence: .high,
|
||||||
|
analysisNotes: readings.isEmpty ? "Keine Daten" : "Einzelne Quelle"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply rule to get recommendation
|
||||||
|
let result = ruleEngine.applyRule(to: readings, dataType: dataType)
|
||||||
|
|
||||||
|
// Calculate conflict severity
|
||||||
|
let values = readings.map { $0.value }.filter { $0 > 0 }
|
||||||
|
var severity: ConflictSeverity? = nil
|
||||||
|
|
||||||
|
if values.count >= 2, let min = values.min(), let max = values.max(), min > 0 {
|
||||||
|
let percentDiff = (max - min) / min * 100
|
||||||
|
if percentDiff >= 5 {
|
||||||
|
if percentDiff < 10 { severity = .minor }
|
||||||
|
else if percentDiff < 25 { severity = .moderate }
|
||||||
|
else if percentDiff < 50 { severity = .significant }
|
||||||
|
else { severity = .major }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let alternativeReadings = readings.filter { $0.id != result.selectedReading?.id }
|
||||||
|
|
||||||
|
return WindowAnalysis(
|
||||||
|
windowData: windowData,
|
||||||
|
hasConflict: windowData.hasConflict,
|
||||||
|
conflictSeverity: severity,
|
||||||
|
recommendedReading: result.selectedReading,
|
||||||
|
alternativeReadings: alternativeReadings,
|
||||||
|
confidence: result.confidence,
|
||||||
|
analysisNotes: result.reason
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Resolve Conflict
|
||||||
|
|
||||||
|
func resolveConflict(_ conflict: Conflict, using result: RuleApplicationResult) -> ConflictResolution? {
|
||||||
|
guard let selectedReading = result.selectedReading else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConflictResolution(
|
||||||
|
resolvedValue: selectedReading.value,
|
||||||
|
secondaryResolvedValue: selectedReading.secondaryValue,
|
||||||
|
winningSourceId: selectedReading.sourceId,
|
||||||
|
strategy: result.strategy,
|
||||||
|
isManual: result.strategy == .manual
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveConflictManually(
|
||||||
|
_ conflict: Conflict,
|
||||||
|
selectedReadingId: UUID
|
||||||
|
) -> ConflictResolution? {
|
||||||
|
guard let selectedReading = conflict.readings.first(where: { $0.id == selectedReadingId }) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConflictResolution(
|
||||||
|
resolvedValue: selectedReading.value,
|
||||||
|
secondaryResolvedValue: selectedReading.secondaryValue,
|
||||||
|
winningSourceId: selectedReading.sourceId,
|
||||||
|
strategy: .manual,
|
||||||
|
isManual: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Create Merged Record
|
||||||
|
|
||||||
|
func createMergedRecord(from conflict: Conflict, resolution: ConflictResolution) -> MergedRecord {
|
||||||
|
return MergedRecord(
|
||||||
|
id: UUID(),
|
||||||
|
dataType: conflict.dataType,
|
||||||
|
timeWindow: conflict.timeWindow,
|
||||||
|
value: resolution.resolvedValue,
|
||||||
|
secondaryValue: resolution.secondaryResolvedValue,
|
||||||
|
originalSourceId: resolution.winningSourceId,
|
||||||
|
strategy: resolution.strategy,
|
||||||
|
createdAt: Date(),
|
||||||
|
metadata: [
|
||||||
|
"conflictId": conflict.id.uuidString,
|
||||||
|
"originalSourceCount": String(conflict.readings.count),
|
||||||
|
"isManualResolution": String(resolution.isManual)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Batch Processing
|
||||||
|
|
||||||
|
func processConflicts(_ conflicts: [Conflict]) async -> [MergeOperation] {
|
||||||
|
isMerging = true
|
||||||
|
defer { isMerging = false }
|
||||||
|
|
||||||
|
var operations: [MergeOperation] = []
|
||||||
|
|
||||||
|
for (index, conflict) in conflicts.enumerated() {
|
||||||
|
mergeProgress = Double(index) / Double(conflicts.count)
|
||||||
|
|
||||||
|
let result = ruleEngine.applyRule(to: conflict.readings, dataType: conflict.dataType)
|
||||||
|
|
||||||
|
if result.confidence == .requiresManual ||
|
||||||
|
ruleEngine.shouldRequestManualReview(readings: conflict.readings, dataType: conflict.dataType) {
|
||||||
|
// Add to pending for manual review
|
||||||
|
let operation = MergeOperation(
|
||||||
|
conflict: conflict,
|
||||||
|
status: .pendingManualReview,
|
||||||
|
result: result
|
||||||
|
)
|
||||||
|
pendingMerges.append(operation)
|
||||||
|
operations.append(operation)
|
||||||
|
} else if let resolution = resolveConflict(conflict, using: result) {
|
||||||
|
// Auto-resolve
|
||||||
|
let mergedRecord = createMergedRecord(from: conflict, resolution: resolution)
|
||||||
|
let operation = MergeOperation(
|
||||||
|
conflict: conflict,
|
||||||
|
status: .resolved,
|
||||||
|
result: result,
|
||||||
|
resolution: resolution,
|
||||||
|
mergedRecord: mergedRecord
|
||||||
|
)
|
||||||
|
completedMerges.append(operation)
|
||||||
|
operations.append(operation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mergeProgress = 1.0
|
||||||
|
return operations
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Daily Merge
|
||||||
|
|
||||||
|
func performDailyMerge(for date: Date) async throws -> DailyMergeReport {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let startOfDay = calendar.startOfDay(for: date)
|
||||||
|
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
|
||||||
|
|
||||||
|
var report = DailyMergeReport(date: date)
|
||||||
|
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
do {
|
||||||
|
let windowData = try await dataReader.fetchData(
|
||||||
|
for: dataType,
|
||||||
|
from: startOfDay,
|
||||||
|
to: endOfDay
|
||||||
|
)
|
||||||
|
|
||||||
|
let conflictWindows = windowData.filter { $0.hasConflict }
|
||||||
|
report.totalConflicts += conflictWindows.count
|
||||||
|
|
||||||
|
for window in conflictWindows {
|
||||||
|
let analysis = analyze(windowData: window)
|
||||||
|
|
||||||
|
if analysis.confidence == .requiresManual {
|
||||||
|
report.pendingManualReview += 1
|
||||||
|
} else {
|
||||||
|
report.autoResolved += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
report.analysesByType[dataType, default: []].append(analysis)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
report.errors.append("Fehler bei \(dataType.displayName): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting Types
|
||||||
|
|
||||||
|
struct WindowAnalysis {
|
||||||
|
let windowData: TimeWindowData
|
||||||
|
let hasConflict: Bool
|
||||||
|
let conflictSeverity: ConflictSeverity?
|
||||||
|
let recommendedReading: SourceReading?
|
||||||
|
let alternativeReadings: [SourceReading]
|
||||||
|
let confidence: RuleConfidence
|
||||||
|
let analysisNotes: String
|
||||||
|
|
||||||
|
var recommendedValue: Double? {
|
||||||
|
recommendedReading?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
var valueDifference: Double? {
|
||||||
|
guard let recommended = recommendedReading?.value,
|
||||||
|
let alternative = alternativeReadings.first?.value else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return abs(recommended - alternative)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MergeOperation: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let conflict: Conflict
|
||||||
|
var status: MergeStatus
|
||||||
|
let result: RuleApplicationResult
|
||||||
|
var resolution: ConflictResolution?
|
||||||
|
var mergedRecord: MergedRecord?
|
||||||
|
let createdAt = Date()
|
||||||
|
var processedAt: Date?
|
||||||
|
|
||||||
|
enum MergeStatus {
|
||||||
|
case pending
|
||||||
|
case pendingManualReview
|
||||||
|
case resolved
|
||||||
|
case written
|
||||||
|
case failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MergedRecord: Identifiable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let timeWindow: TimeWindow
|
||||||
|
let value: Double
|
||||||
|
let secondaryValue: Double?
|
||||||
|
let originalSourceId: String
|
||||||
|
let strategy: MergeStrategy
|
||||||
|
let createdAt: Date
|
||||||
|
var writtenAt: Date?
|
||||||
|
var healthKitRecordId: String?
|
||||||
|
var metadata: [String: String]
|
||||||
|
|
||||||
|
var formattedValue: String {
|
||||||
|
if value == floor(value) {
|
||||||
|
return String(format: "%.0f", value)
|
||||||
|
}
|
||||||
|
return String(format: "%.1f", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DailyMergeReport {
|
||||||
|
let date: Date
|
||||||
|
var totalConflicts = 0
|
||||||
|
var autoResolved = 0
|
||||||
|
var pendingManualReview = 0
|
||||||
|
var analysesByType: [HealthDataType: [WindowAnalysis]] = [:]
|
||||||
|
var errors: [String] = []
|
||||||
|
let generatedAt = Date()
|
||||||
|
|
||||||
|
var successRate: Double {
|
||||||
|
guard totalConflicts > 0 else { return 1.0 }
|
||||||
|
return Double(autoResolved) / Double(totalConflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary: String {
|
||||||
|
if totalConflicts == 0 {
|
||||||
|
return "Keine Konflikte gefunden"
|
||||||
|
}
|
||||||
|
return "\(autoResolved)/\(totalConflicts) automatisch gelöst, \(pendingManualReview) zur Prüfung"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - Rule Engine
|
||||||
|
@MainActor
|
||||||
|
class RuleEngine: ObservableObject {
|
||||||
|
static let shared = RuleEngine()
|
||||||
|
|
||||||
|
@Published var rules: [HealthDataType: MergeRule] = [:]
|
||||||
|
@Published var isLoaded = false
|
||||||
|
|
||||||
|
private let storage = RuleStorageManager()
|
||||||
|
private let sourceManager = SourceManager.shared
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
loadRules()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rule Loading
|
||||||
|
|
||||||
|
func loadRules() {
|
||||||
|
let savedRules = storage.loadRules()
|
||||||
|
|
||||||
|
if savedRules.isEmpty {
|
||||||
|
// Initialize with defaults
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
rules[dataType] = MergeRule.defaultRule(for: dataType)
|
||||||
|
}
|
||||||
|
saveRules()
|
||||||
|
} else {
|
||||||
|
rules = savedRules
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoaded = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveRules() {
|
||||||
|
storage.saveRules(rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rule Access
|
||||||
|
|
||||||
|
func getRule(for dataType: HealthDataType) -> MergeRule {
|
||||||
|
return rules[dataType] ?? MergeRule.defaultRule(for: dataType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setRule(_ rule: MergeRule, for dataType: HealthDataType) {
|
||||||
|
rules[dataType] = rule
|
||||||
|
saveRules()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetToDefault(for dataType: HealthDataType) {
|
||||||
|
rules[dataType] = MergeRule.defaultRule(for: dataType)
|
||||||
|
saveRules()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetAllToDefaults() {
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
rules[dataType] = MergeRule.defaultRule(for: dataType)
|
||||||
|
}
|
||||||
|
saveRules()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rule Application
|
||||||
|
|
||||||
|
func applyRule(
|
||||||
|
to readings: [SourceReading],
|
||||||
|
dataType: HealthDataType
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
let rule = getRule(for: dataType)
|
||||||
|
|
||||||
|
// Filter out empty/zero readings for most strategies
|
||||||
|
let validReadings = readings.filter { $0.value > 0 || $0.quality == .complete }
|
||||||
|
|
||||||
|
guard !validReadings.isEmpty else {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: rule.strategy,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine gültigen Werte vorhanden"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If only one valid reading, no conflict
|
||||||
|
if validReadings.count == 1 {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: validReadings[0],
|
||||||
|
strategy: rule.strategy,
|
||||||
|
confidence: .high,
|
||||||
|
reason: "Nur eine Quelle verfügbar"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply strategy
|
||||||
|
switch rule.strategy {
|
||||||
|
case .exclusive:
|
||||||
|
return applyExclusiveStrategy(readings: validReadings, rule: rule)
|
||||||
|
|
||||||
|
case .priority:
|
||||||
|
return applyPriorityStrategy(readings: validReadings, rule: rule, dataType: dataType)
|
||||||
|
|
||||||
|
case .higherWins:
|
||||||
|
return applyHigherWinsStrategy(readings: validReadings, rule: rule)
|
||||||
|
|
||||||
|
case .lowerWins:
|
||||||
|
return applyLowerWinsStrategy(readings: validReadings, rule: rule)
|
||||||
|
|
||||||
|
case .average:
|
||||||
|
return applyAverageStrategy(readings: validReadings, rule: rule)
|
||||||
|
|
||||||
|
case .coverage:
|
||||||
|
return applyCoverageStrategy(readings: validReadings, rule: rule)
|
||||||
|
|
||||||
|
case .coverageThenHigher:
|
||||||
|
return applyCoverageThenHigherStrategy(readings: validReadings, rule: rule)
|
||||||
|
|
||||||
|
case .mostRecent:
|
||||||
|
return applyMostRecentStrategy(readings: validReadings, rule: rule)
|
||||||
|
|
||||||
|
case .manual:
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .manual,
|
||||||
|
confidence: .requiresManual,
|
||||||
|
reason: "Manuelle Entscheidung erforderlich"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Strategy Implementations
|
||||||
|
|
||||||
|
private func applyExclusiveStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
// If primary source is specified, use it
|
||||||
|
if let primaryId = rule.primarySourceId,
|
||||||
|
let reading = readings.first(where: { $0.sourceId == primaryId }) {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: reading,
|
||||||
|
strategy: .exclusive,
|
||||||
|
confidence: .high,
|
||||||
|
reason: "Exklusive Quelle: \(reading.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise use highest priority source
|
||||||
|
let sorted = readings.sorted { $0.sourceCategory.priority > $1.sourceCategory.priority }
|
||||||
|
if let first = sorted.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: first,
|
||||||
|
strategy: .exclusive,
|
||||||
|
confidence: .high,
|
||||||
|
reason: "Höchste Priorität: \(first.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .exclusive,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine geeignete Quelle gefunden"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyPriorityStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule,
|
||||||
|
dataType: HealthDataType
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
// Sort by user-defined priority, then by category priority
|
||||||
|
let sorted = readings.sorted { r1, r2 in
|
||||||
|
let p1 = rule.sourcePriorities[r1.sourceId] ?? r1.sourceCategory.priority
|
||||||
|
let p2 = rule.sourcePriorities[r2.sourceId] ?? r2.sourceCategory.priority
|
||||||
|
return p1 > p2
|
||||||
|
}
|
||||||
|
|
||||||
|
if let first = sorted.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: first,
|
||||||
|
strategy: .priority,
|
||||||
|
confidence: .high,
|
||||||
|
reason: "Höchste Priorität: \(first.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .priority,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine Quelle mit Priorität gefunden"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyHigherWinsStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
let sorted = readings.sorted { $0.value > $1.value }
|
||||||
|
|
||||||
|
if let highest = sorted.first {
|
||||||
|
// Check if there's a significant difference
|
||||||
|
let values = readings.map { $0.value }
|
||||||
|
let spread = (values.max() ?? 0) - (values.min() ?? 0)
|
||||||
|
let avgValue = values.reduce(0, +) / Double(values.count)
|
||||||
|
let spreadPercent = avgValue > 0 ? (spread / avgValue) * 100 : 0
|
||||||
|
|
||||||
|
let confidence: RuleConfidence = spreadPercent < 10 ? .high : .medium
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: highest,
|
||||||
|
strategy: .higherWins,
|
||||||
|
confidence: confidence,
|
||||||
|
reason: "Höchster Wert: \(highest.formattedValue) von \(highest.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .higherWins,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine Werte zum Vergleich"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyLowerWinsStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
let sorted = readings.sorted { $0.value < $1.value }
|
||||||
|
|
||||||
|
if let lowest = sorted.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: lowest,
|
||||||
|
strategy: .lowerWins,
|
||||||
|
confidence: .medium,
|
||||||
|
reason: "Niedrigster Wert: \(lowest.formattedValue) von \(lowest.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .lowerWins,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine Werte zum Vergleich"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyAverageStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
let values = readings.map { $0.value }
|
||||||
|
let average = values.reduce(0, +) / Double(values.count)
|
||||||
|
|
||||||
|
// Create a synthetic reading for the average
|
||||||
|
let syntheticReading = SourceReading(
|
||||||
|
sourceId: HealthBridgeConstants.bundleIdentifier,
|
||||||
|
sourceName: "Durchschnitt",
|
||||||
|
sourceCategory: .healthBridge,
|
||||||
|
value: average,
|
||||||
|
timestamp: readings.first?.timestamp ?? Date(),
|
||||||
|
quality: .complete
|
||||||
|
)
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: syntheticReading,
|
||||||
|
strategy: .average,
|
||||||
|
confidence: .medium,
|
||||||
|
reason: "Durchschnitt aus \(readings.count) Quellen"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyCoverageStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
// Prefer readings with complete quality
|
||||||
|
let completeReadings = readings.filter { $0.quality == .complete }
|
||||||
|
|
||||||
|
if completeReadings.count == 1 {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: completeReadings[0],
|
||||||
|
strategy: .coverage,
|
||||||
|
confidence: .high,
|
||||||
|
reason: "Einzige Quelle mit vollständigen Daten: \(completeReadings[0].sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If multiple complete readings, fall back to priority
|
||||||
|
if !completeReadings.isEmpty {
|
||||||
|
let sorted = completeReadings.sorted { $0.sourceCategory.priority > $1.sourceCategory.priority }
|
||||||
|
if let first = sorted.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: first,
|
||||||
|
strategy: .coverage,
|
||||||
|
confidence: .medium,
|
||||||
|
reason: "Mehrere Quellen verfügbar, gewählt: \(first.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No complete readings, use any reading with highest priority
|
||||||
|
let sorted = readings.sorted { $0.sourceCategory.priority > $1.sourceCategory.priority }
|
||||||
|
if let first = sorted.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: first,
|
||||||
|
strategy: .coverage,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine vollständigen Daten, gewählt: \(first.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .coverage,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine Quelle mit Daten gefunden"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyCoverageThenHigherStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
// First check if one source has data and others don't (coverage)
|
||||||
|
let nonZeroReadings = readings.filter { $0.value > 0 }
|
||||||
|
let zeroReadings = readings.filter { $0.value == 0 }
|
||||||
|
|
||||||
|
// If only one source has data, it wins on coverage
|
||||||
|
if nonZeroReadings.count == 1 && !zeroReadings.isEmpty {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nonZeroReadings[0],
|
||||||
|
strategy: .coverageThenHigher,
|
||||||
|
confidence: .high,
|
||||||
|
reason: "Einzige Quelle mit Daten: \(nonZeroReadings[0].sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple sources have data, use higher wins
|
||||||
|
if nonZeroReadings.count > 1 {
|
||||||
|
let sorted = nonZeroReadings.sorted { $0.value > $1.value }
|
||||||
|
if let highest = sorted.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: highest,
|
||||||
|
strategy: .coverageThenHigher,
|
||||||
|
confidence: .medium,
|
||||||
|
reason: "Höherer Wert bei Konflikt: \(highest.formattedValue) von \(highest.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
if let first = readings.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: first,
|
||||||
|
strategy: .coverageThenHigher,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Fallback auf erste Quelle"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .coverageThenHigher,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine Daten verfügbar"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyMostRecentStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
let sorted = readings.sorted { $0.timestamp > $1.timestamp }
|
||||||
|
|
||||||
|
if let mostRecent = sorted.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: mostRecent,
|
||||||
|
strategy: .mostRecent,
|
||||||
|
confidence: .high,
|
||||||
|
reason: "Neuester Wert von \(mostRecent.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .mostRecent,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine Zeitstempel verfügbar"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Threshold Check
|
||||||
|
|
||||||
|
func shouldRequestManualReview(
|
||||||
|
readings: [SourceReading],
|
||||||
|
dataType: HealthDataType
|
||||||
|
) -> Bool {
|
||||||
|
let rule = getRule(for: dataType)
|
||||||
|
|
||||||
|
guard let threshold = rule.thresholdForManualReview else {
|
||||||
|
return rule.strategy == .manual
|
||||||
|
}
|
||||||
|
|
||||||
|
let values = readings.map { $0.value }.filter { $0 > 0 }
|
||||||
|
guard values.count >= 2,
|
||||||
|
let min = values.min(),
|
||||||
|
let max = values.max(),
|
||||||
|
min > 0 else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let percentDiff = (max - min) / min * 100
|
||||||
|
return percentDiff > threshold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rule Application Result
|
||||||
|
struct RuleApplicationResult {
|
||||||
|
let selectedReading: SourceReading?
|
||||||
|
let strategy: MergeStrategy
|
||||||
|
let confidence: RuleConfidence
|
||||||
|
let reason: String
|
||||||
|
|
||||||
|
var resolvedValue: Double? {
|
||||||
|
selectedReading?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
var winningSourceId: String? {
|
||||||
|
selectedReading?.sourceId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RuleConfidence: String, Codable {
|
||||||
|
case high = "high"
|
||||||
|
case medium = "medium"
|
||||||
|
case low = "low"
|
||||||
|
case requiresManual = "requires_manual"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .high: return "Hohe Sicherheit"
|
||||||
|
case .medium: return "Mittlere Sicherheit"
|
||||||
|
case .low: return "Geringe Sicherheit"
|
||||||
|
case .requiresManual: return "Manuelle Prüfung"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .high: return "checkmark.seal.fill"
|
||||||
|
case .medium: return "checkmark.seal"
|
||||||
|
case .low: return "questionmark.circle"
|
||||||
|
case .requiresManual: return "hand.raised.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rule Storage Manager
|
||||||
|
class RuleStorageManager {
|
||||||
|
private let userDefaults = UserDefaults.standard
|
||||||
|
private let rulesKey = "healthbridge.merge.rules"
|
||||||
|
|
||||||
|
func saveRules(_ rules: [HealthDataType: MergeRule]) {
|
||||||
|
do {
|
||||||
|
let data = try JSONEncoder().encode(rules)
|
||||||
|
userDefaults.set(data, forKey: rulesKey)
|
||||||
|
} catch {
|
||||||
|
print("Failed to save rules: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRules() -> [HealthDataType: MergeRule] {
|
||||||
|
guard let data = userDefaults.data(forKey: rulesKey) else {
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode([HealthDataType: MergeRule].self, from: data)
|
||||||
|
} catch {
|
||||||
|
print("Failed to load rules: \(error)")
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Blood Pressure Handler
|
||||||
|
class BloodPressureHandler {
|
||||||
|
static let shared = BloodPressureHandler()
|
||||||
|
|
||||||
|
struct ValidationResult {
|
||||||
|
let isValid: Bool
|
||||||
|
let issues: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
func validate(systolic: Double, diastolic: Double) -> ValidationResult {
|
||||||
|
var issues: [String] = []
|
||||||
|
|
||||||
|
// Range validation
|
||||||
|
if systolic < 70 || systolic > 200 {
|
||||||
|
issues.append("Systolischer Wert ausserhalb des Normalbereichs (70-200 mmHg)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if diastolic < 40 || diastolic > 130 {
|
||||||
|
issues.append("Diastolischer Wert ausserhalb des Normalbereichs (40-130 mmHg)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plausibility check
|
||||||
|
if diastolic >= systolic {
|
||||||
|
issues.append("Diastolischer Wert muss kleiner als systolischer Wert sein")
|
||||||
|
}
|
||||||
|
|
||||||
|
if systolic - diastolic < 20 {
|
||||||
|
issues.append("Pulsdruck zu gering (< 20 mmHg)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if systolic - diastolic > 100 {
|
||||||
|
issues.append("Pulsdruck zu hoch (> 100 mmHg)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValidationResult(isValid: issues.isEmpty, issues: issues)
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyBloodPressure(systolic: Double, diastolic: Double) -> BloodPressureClassification {
|
||||||
|
if systolic < 120 && diastolic < 80 {
|
||||||
|
return .normal
|
||||||
|
} else if systolic < 130 && diastolic < 80 {
|
||||||
|
return .elevated
|
||||||
|
} else if systolic < 140 || diastolic < 90 {
|
||||||
|
return .hypertensionStage1
|
||||||
|
} else if systolic < 180 || diastolic < 120 {
|
||||||
|
return .hypertensionStage2
|
||||||
|
} else {
|
||||||
|
return .hypertensiveCrisis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BloodPressureClassification: String {
|
||||||
|
case normal = "Normal"
|
||||||
|
case elevated = "Erhöht"
|
||||||
|
case hypertensionStage1 = "Bluthochdruck Stufe 1"
|
||||||
|
case hypertensionStage2 = "Bluthochdruck Stufe 2"
|
||||||
|
case hypertensiveCrisis = "Hypertensive Krise"
|
||||||
|
|
||||||
|
var color: String {
|
||||||
|
switch self {
|
||||||
|
case .normal: return "green"
|
||||||
|
case .elevated: return "yellow"
|
||||||
|
case .hypertensionStage1: return "orange"
|
||||||
|
case .hypertensionStage2: return "red"
|
||||||
|
case .hypertensiveCrisis: return "purple"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var recommendation: String {
|
||||||
|
switch self {
|
||||||
|
case .normal:
|
||||||
|
return "Weiter so! Regelmässige Kontrolle empfohlen."
|
||||||
|
case .elevated:
|
||||||
|
return "Lebensstiländerungen empfohlen. Mehr Bewegung, weniger Salz."
|
||||||
|
case .hypertensionStage1:
|
||||||
|
return "Arztbesuch empfohlen. Möglicherweise Medikation erforderlich."
|
||||||
|
case .hypertensionStage2:
|
||||||
|
return "Zeitnaher Arztbesuch erforderlich. Medikation wahrscheinlich notwendig."
|
||||||
|
case .hypertensiveCrisis:
|
||||||
|
return "SOFORT medizinische Hilfe aufsuchen!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
import Foundation
|
||||||
|
import HealthKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - Source Manager
|
||||||
|
@MainActor
|
||||||
|
class SourceManager: ObservableObject {
|
||||||
|
static let shared = SourceManager()
|
||||||
|
|
||||||
|
@Published var sources: [HealthSource] = []
|
||||||
|
@Published var sourceProfiles: [String: SourceProfile] = [:]
|
||||||
|
@Published var isDiscovering = false
|
||||||
|
|
||||||
|
private let healthKitManager = HealthKitManager.shared
|
||||||
|
private let storage = SourceStorageManager()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Source Discovery
|
||||||
|
|
||||||
|
func discoverSources() async {
|
||||||
|
isDiscovering = true
|
||||||
|
defer { isDiscovering = false }
|
||||||
|
|
||||||
|
await healthKitManager.discoverSources()
|
||||||
|
sources = healthKitManager.discoveredSources
|
||||||
|
|
||||||
|
// Load saved source profiles and merge with discovered sources
|
||||||
|
let savedProfiles = storage.loadSourceProfiles()
|
||||||
|
for source in sources {
|
||||||
|
if let savedProfile = savedProfiles[source.bundleIdentifier] {
|
||||||
|
sourceProfiles[source.bundleIdentifier] = savedProfile
|
||||||
|
} else {
|
||||||
|
sourceProfiles[source.bundleIdentifier] = SourceProfile(source: source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Classification
|
||||||
|
|
||||||
|
func classifySource(_ bundleIdentifier: String) -> SourceCategory {
|
||||||
|
let lowercased = bundleIdentifier.lowercased()
|
||||||
|
|
||||||
|
// HealthBridge
|
||||||
|
if lowercased.contains("healthbridge") {
|
||||||
|
return .healthBridge
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apple Devices
|
||||||
|
if lowercased.contains("com.apple") {
|
||||||
|
if lowercased.contains("watch") || lowercased.contains("nano") {
|
||||||
|
return .watch
|
||||||
|
}
|
||||||
|
if lowercased.contains("health") {
|
||||||
|
return .iPhone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known Watch Brands
|
||||||
|
let watchBrands = ["huawei", "samsung", "galaxy", "fitbit", "garmin", "polar",
|
||||||
|
"withings", "amazfit", "xiaomi", "honor", "oppo", "suunto",
|
||||||
|
"coros", "whoop", "oura"]
|
||||||
|
for brand in watchBrands {
|
||||||
|
if lowercased.contains(brand) {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known Health Apps
|
||||||
|
let healthApps = ["strava", "nike", "adidas", "runtastic", "runkeeper",
|
||||||
|
"mapmyrun", "endomondo", "myfitnesspal", "flo", "clue"]
|
||||||
|
for app in healthApps {
|
||||||
|
if lowercased.contains(app) {
|
||||||
|
return .thirdPartyApp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Capabilities
|
||||||
|
|
||||||
|
func getSourceCapabilities(_ source: HealthSource) -> SourceCapabilities {
|
||||||
|
let category = source.category
|
||||||
|
|
||||||
|
switch category {
|
||||||
|
case .iPhone:
|
||||||
|
return SourceCapabilities(
|
||||||
|
canMeasureSteps: true,
|
||||||
|
canMeasureDistance: true,
|
||||||
|
canMeasureFloors: true,
|
||||||
|
canMeasureHeartRate: false,
|
||||||
|
canMeasureBloodPressure: false,
|
||||||
|
canMeasureBloodOxygen: false,
|
||||||
|
canMeasureSleep: false,
|
||||||
|
canMeasureWorkouts: true,
|
||||||
|
hasGPS: true,
|
||||||
|
hasBarometer: true,
|
||||||
|
hasAccelerometer: true
|
||||||
|
)
|
||||||
|
|
||||||
|
case .watch:
|
||||||
|
return SourceCapabilities(
|
||||||
|
canMeasureSteps: true,
|
||||||
|
canMeasureDistance: true,
|
||||||
|
canMeasureFloors: false,
|
||||||
|
canMeasureHeartRate: true,
|
||||||
|
canMeasureBloodPressure: false,
|
||||||
|
canMeasureBloodOxygen: true,
|
||||||
|
canMeasureSleep: true,
|
||||||
|
canMeasureWorkouts: true,
|
||||||
|
hasGPS: true,
|
||||||
|
hasBarometer: false,
|
||||||
|
hasAccelerometer: true
|
||||||
|
)
|
||||||
|
|
||||||
|
case .thirdPartyWatch:
|
||||||
|
// Check for specific features based on name
|
||||||
|
let name = source.name.lowercased()
|
||||||
|
let isHuaweiD2 = name.contains("huawei") && (name.contains("d2") || name.contains("watch d"))
|
||||||
|
|
||||||
|
return SourceCapabilities(
|
||||||
|
canMeasureSteps: true,
|
||||||
|
canMeasureDistance: true,
|
||||||
|
canMeasureFloors: false,
|
||||||
|
canMeasureHeartRate: true,
|
||||||
|
canMeasureBloodPressure: isHuaweiD2, // Huawei Watch D2 has BP sensor
|
||||||
|
canMeasureBloodOxygen: true,
|
||||||
|
canMeasureSleep: true,
|
||||||
|
canMeasureWorkouts: true,
|
||||||
|
hasGPS: true,
|
||||||
|
hasBarometer: false,
|
||||||
|
hasAccelerometer: true
|
||||||
|
)
|
||||||
|
|
||||||
|
case .thirdPartyApp:
|
||||||
|
return SourceCapabilities(
|
||||||
|
canMeasureSteps: true,
|
||||||
|
canMeasureDistance: true,
|
||||||
|
canMeasureFloors: false,
|
||||||
|
canMeasureHeartRate: false,
|
||||||
|
canMeasureBloodPressure: false,
|
||||||
|
canMeasureBloodOxygen: false,
|
||||||
|
canMeasureSleep: false,
|
||||||
|
canMeasureWorkouts: true,
|
||||||
|
hasGPS: true,
|
||||||
|
hasBarometer: false,
|
||||||
|
hasAccelerometer: false
|
||||||
|
)
|
||||||
|
|
||||||
|
case .healthBridge, .unknown:
|
||||||
|
return SourceCapabilities.empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Health
|
||||||
|
|
||||||
|
func getSourceHealth(_ source: HealthSource) async -> SourceHealthReport {
|
||||||
|
var dataGaps: [HealthDataType: [TimeWindow]] = [:]
|
||||||
|
var lastActivityByType: [HealthDataType: Date] = [:]
|
||||||
|
var recordCountByType: [HealthDataType: Int] = [:]
|
||||||
|
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let now = Date()
|
||||||
|
let yesterday = calendar.date(byAdding: .day, value: -1, to: now)!
|
||||||
|
|
||||||
|
for dataType in source.supportedDataTypes {
|
||||||
|
do {
|
||||||
|
let samples = try await healthKitManager.fetchSamples(
|
||||||
|
for: dataType,
|
||||||
|
from: yesterday,
|
||||||
|
to: now
|
||||||
|
)
|
||||||
|
|
||||||
|
let matchingSamples = samples.filter {
|
||||||
|
$0.sourceRevision.source.bundleIdentifier == source.bundleIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
recordCountByType[dataType] = matchingSamples.count
|
||||||
|
|
||||||
|
if let lastSample = matchingSamples.last {
|
||||||
|
lastActivityByType[dataType] = lastSample.endDate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect gaps
|
||||||
|
let gaps = detectDataGaps(
|
||||||
|
in: matchingSamples,
|
||||||
|
from: yesterday,
|
||||||
|
to: now,
|
||||||
|
expectedInterval: 15 * 60 // 15 minutes
|
||||||
|
)
|
||||||
|
if !gaps.isEmpty {
|
||||||
|
dataGaps[dataType] = gaps
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Error checking health for \(dataType): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let overallQuality: DataQuality
|
||||||
|
if recordCountByType.values.reduce(0, +) == 0 {
|
||||||
|
overallQuality = .missing
|
||||||
|
} else if dataGaps.isEmpty {
|
||||||
|
overallQuality = .complete
|
||||||
|
} else {
|
||||||
|
overallQuality = .partial
|
||||||
|
}
|
||||||
|
|
||||||
|
return SourceHealthReport(
|
||||||
|
source: source,
|
||||||
|
lastActivityByType: lastActivityByType,
|
||||||
|
recordCountByType: recordCountByType,
|
||||||
|
dataGaps: dataGaps,
|
||||||
|
overallQuality: overallQuality,
|
||||||
|
checkedAt: Date()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func detectDataGaps(
|
||||||
|
in samples: [HKSample],
|
||||||
|
from start: Date,
|
||||||
|
to end: Date,
|
||||||
|
expectedInterval: TimeInterval
|
||||||
|
) -> [TimeWindow] {
|
||||||
|
guard !samples.isEmpty else {
|
||||||
|
return [TimeWindow(start: start, end: end)]
|
||||||
|
}
|
||||||
|
|
||||||
|
var gaps: [TimeWindow] = []
|
||||||
|
let sortedSamples = samples.sorted { $0.startDate < $1.startDate }
|
||||||
|
|
||||||
|
// Check gap at beginning
|
||||||
|
if let firstSample = sortedSamples.first,
|
||||||
|
firstSample.startDate.timeIntervalSince(start) > expectedInterval * 2 {
|
||||||
|
gaps.append(TimeWindow(start: start, end: firstSample.startDate))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check gaps between samples
|
||||||
|
for i in 0..<(sortedSamples.count - 1) {
|
||||||
|
let currentEnd = sortedSamples[i].endDate
|
||||||
|
let nextStart = sortedSamples[i + 1].startDate
|
||||||
|
let gap = nextStart.timeIntervalSince(currentEnd)
|
||||||
|
|
||||||
|
if gap > expectedInterval * 2 {
|
||||||
|
gaps.append(TimeWindow(start: currentEnd, end: nextStart))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check gap at end
|
||||||
|
if let lastSample = sortedSamples.last,
|
||||||
|
end.timeIntervalSince(lastSample.endDate) > expectedInterval * 2 {
|
||||||
|
gaps.append(TimeWindow(start: lastSample.endDate, end: end))
|
||||||
|
}
|
||||||
|
|
||||||
|
return gaps
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Priority Management
|
||||||
|
|
||||||
|
func setPriority(_ priority: Int, for source: HealthSource, dataType: HealthDataType) {
|
||||||
|
guard var profile = sourceProfiles[source.bundleIdentifier] else { return }
|
||||||
|
profile.priorities[dataType] = priority
|
||||||
|
sourceProfiles[source.bundleIdentifier] = profile
|
||||||
|
storage.saveSourceProfiles(sourceProfiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPriority(for source: HealthSource, dataType: HealthDataType) -> Int {
|
||||||
|
if let profile = sourceProfiles[source.bundleIdentifier],
|
||||||
|
let priority = profile.priorities[dataType] {
|
||||||
|
return priority
|
||||||
|
}
|
||||||
|
return source.category.priority
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSourcesByPriority(for dataType: HealthDataType) -> [HealthSource] {
|
||||||
|
return sources
|
||||||
|
.filter { $0.supportedDataTypes.contains(dataType) }
|
||||||
|
.sorted { getPriority(for: $0, dataType: dataType) > getPriority(for: $1, dataType: dataType) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Enable/Disable
|
||||||
|
|
||||||
|
func setEnabled(_ enabled: Bool, for source: HealthSource) {
|
||||||
|
guard var profile = sourceProfiles[source.bundleIdentifier] else { return }
|
||||||
|
profile.isEnabled = enabled
|
||||||
|
sourceProfiles[source.bundleIdentifier] = profile
|
||||||
|
storage.saveSourceProfiles(sourceProfiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEnabled(_ source: HealthSource) -> Bool {
|
||||||
|
return sourceProfiles[source.bundleIdentifier]?.isEnabled ?? true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Profile
|
||||||
|
struct SourceProfile: Codable {
|
||||||
|
let bundleIdentifier: String
|
||||||
|
var priorities: [HealthDataType: Int]
|
||||||
|
var isEnabled: Bool
|
||||||
|
var customName: String?
|
||||||
|
var notes: String?
|
||||||
|
let addedAt: Date
|
||||||
|
|
||||||
|
init(source: HealthSource) {
|
||||||
|
self.bundleIdentifier = source.bundleIdentifier
|
||||||
|
self.priorities = [:]
|
||||||
|
self.isEnabled = true
|
||||||
|
self.customName = nil
|
||||||
|
self.notes = nil
|
||||||
|
self.addedAt = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Capabilities
|
||||||
|
struct SourceCapabilities {
|
||||||
|
let canMeasureSteps: Bool
|
||||||
|
let canMeasureDistance: Bool
|
||||||
|
let canMeasureFloors: Bool
|
||||||
|
let canMeasureHeartRate: Bool
|
||||||
|
let canMeasureBloodPressure: Bool
|
||||||
|
let canMeasureBloodOxygen: Bool
|
||||||
|
let canMeasureSleep: Bool
|
||||||
|
let canMeasureWorkouts: Bool
|
||||||
|
let hasGPS: Bool
|
||||||
|
let hasBarometer: Bool
|
||||||
|
let hasAccelerometer: Bool
|
||||||
|
|
||||||
|
static let empty = SourceCapabilities(
|
||||||
|
canMeasureSteps: false,
|
||||||
|
canMeasureDistance: false,
|
||||||
|
canMeasureFloors: false,
|
||||||
|
canMeasureHeartRate: false,
|
||||||
|
canMeasureBloodPressure: false,
|
||||||
|
canMeasureBloodOxygen: false,
|
||||||
|
canMeasureSleep: false,
|
||||||
|
canMeasureWorkouts: false,
|
||||||
|
hasGPS: false,
|
||||||
|
hasBarometer: false,
|
||||||
|
hasAccelerometer: false
|
||||||
|
)
|
||||||
|
|
||||||
|
var supportedDataTypes: Set<HealthDataType> {
|
||||||
|
var types = Set<HealthDataType>()
|
||||||
|
if canMeasureSteps { types.insert(.steps) }
|
||||||
|
if canMeasureDistance { types.insert(.distance) }
|
||||||
|
if canMeasureFloors { types.insert(.floorsClimbed) }
|
||||||
|
if canMeasureHeartRate {
|
||||||
|
types.insert(.heartRate)
|
||||||
|
types.insert(.restingHeartRate)
|
||||||
|
}
|
||||||
|
if canMeasureBloodPressure {
|
||||||
|
types.insert(.bloodPressureSystolic)
|
||||||
|
types.insert(.bloodPressureDiastolic)
|
||||||
|
}
|
||||||
|
if canMeasureBloodOxygen { types.insert(.bloodOxygen) }
|
||||||
|
if canMeasureSleep { types.insert(.sleep) }
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Health Report
|
||||||
|
struct SourceHealthReport {
|
||||||
|
let source: HealthSource
|
||||||
|
let lastActivityByType: [HealthDataType: Date]
|
||||||
|
let recordCountByType: [HealthDataType: Int]
|
||||||
|
let dataGaps: [HealthDataType: [TimeWindow]]
|
||||||
|
let overallQuality: DataQuality
|
||||||
|
let checkedAt: Date
|
||||||
|
|
||||||
|
var lastOverallActivity: Date? {
|
||||||
|
lastActivityByType.values.max()
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalRecordCount: Int {
|
||||||
|
recordCountByType.values.reduce(0, +)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasSignificantGaps: Bool {
|
||||||
|
dataGaps.values.flatMap { $0 }.contains { $0.duration > 3600 } // > 1 hour gap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Storage Manager
|
||||||
|
class SourceStorageManager {
|
||||||
|
private let userDefaults = UserDefaults.standard
|
||||||
|
private let profilesKey = "healthbridge.source.profiles"
|
||||||
|
|
||||||
|
func saveSourceProfiles(_ profiles: [String: SourceProfile]) {
|
||||||
|
do {
|
||||||
|
let data = try JSONEncoder().encode(profiles)
|
||||||
|
userDefaults.set(data, forKey: profilesKey)
|
||||||
|
} catch {
|
||||||
|
print("Failed to save source profiles: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSourceProfiles() -> [String: SourceProfile] {
|
||||||
|
guard let data = userDefaults.data(forKey: profilesKey) else {
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode([String: SourceProfile].self, from: data)
|
||||||
|
} catch {
|
||||||
|
print("Failed to load source profiles: \(error)")
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
// MARK: - Sync Coordinator
|
||||||
|
@MainActor
|
||||||
|
class SyncCoordinator: ObservableObject {
|
||||||
|
static let shared = SyncCoordinator()
|
||||||
|
|
||||||
|
private let healthKitManager = HealthKitManager.shared
|
||||||
|
private let sourceManager = SourceManager.shared
|
||||||
|
private let dataReader = DataReader.shared
|
||||||
|
private let ruleEngine = RuleEngine.shared
|
||||||
|
private let mergeEngine = MergeEngine.shared
|
||||||
|
private let dataWriter = DataWriter.shared
|
||||||
|
|
||||||
|
@Published var isSyncing = false
|
||||||
|
@Published var syncProgress: Double = 0
|
||||||
|
@Published var lastSyncDate: Date?
|
||||||
|
@Published var lastSyncResult: SyncResult?
|
||||||
|
@Published var pendingConflicts: [Conflict] = []
|
||||||
|
@Published var syncHistory: [SyncResult] = []
|
||||||
|
|
||||||
|
private let syncHistoryKey = "healthbridge.sync.history"
|
||||||
|
private let maxHistoryItems = 50
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
loadSyncHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Main Sync
|
||||||
|
|
||||||
|
func performSync(
|
||||||
|
for date: Date = Date(),
|
||||||
|
dataTypes: [HealthDataType] = HealthDataType.allCases
|
||||||
|
) async throws {
|
||||||
|
guard !isSyncing else { return }
|
||||||
|
|
||||||
|
isSyncing = true
|
||||||
|
syncProgress = 0
|
||||||
|
defer { isSyncing = false }
|
||||||
|
|
||||||
|
let startTime = Date()
|
||||||
|
var result = SyncResult(startedAt: startTime)
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Step 1: Refresh sources (10%)
|
||||||
|
syncProgress = 0.05
|
||||||
|
await sourceManager.discoverSources()
|
||||||
|
syncProgress = 0.1
|
||||||
|
|
||||||
|
// Step 2: Detect conflicts (40%)
|
||||||
|
let conflicts = try await dataReader.detectConflicts(for: date, dataTypes: dataTypes)
|
||||||
|
result.totalConflicts = conflicts.count
|
||||||
|
syncProgress = 0.4
|
||||||
|
|
||||||
|
// Step 3: Process conflicts with merge engine (70%)
|
||||||
|
let operations = await mergeEngine.processConflicts(conflicts)
|
||||||
|
syncProgress = 0.7
|
||||||
|
|
||||||
|
let autoResolved = operations.filter { $0.status == .resolved }
|
||||||
|
let pendingManual = operations.filter { $0.status == .pendingManualReview }
|
||||||
|
|
||||||
|
result.autoResolved = autoResolved.count
|
||||||
|
result.pendingManualReview = pendingManual.count
|
||||||
|
|
||||||
|
// Update pending conflicts
|
||||||
|
pendingConflicts = pendingManual.map { $0.conflict }
|
||||||
|
|
||||||
|
// Step 4: Write resolved records (90%)
|
||||||
|
let recordsToWrite = autoResolved.compactMap { $0.mergedRecord }
|
||||||
|
if !recordsToWrite.isEmpty {
|
||||||
|
let writeResult = await dataWriter.writeBatch(recordsToWrite)
|
||||||
|
result.writtenRecords = writeResult.successCount
|
||||||
|
result.writeErrors = writeResult.failureCount
|
||||||
|
}
|
||||||
|
syncProgress = 0.9
|
||||||
|
|
||||||
|
// Step 5: Finalize (100%)
|
||||||
|
result.completedAt = Date()
|
||||||
|
result.status = .success
|
||||||
|
syncProgress = 1.0
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
result.status = .failed
|
||||||
|
result.error = error.localizedDescription
|
||||||
|
result.completedAt = Date()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSyncDate = Date()
|
||||||
|
lastSyncResult = result
|
||||||
|
addToHistory(result)
|
||||||
|
|
||||||
|
// Send notification if there are pending conflicts
|
||||||
|
if result.pendingManualReview > 0 {
|
||||||
|
await sendConflictNotification(count: result.pendingManualReview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Quick Sync
|
||||||
|
|
||||||
|
func performQuickSync() async throws {
|
||||||
|
try await performSync(
|
||||||
|
for: Date(),
|
||||||
|
dataTypes: [.steps, .heartRate, .activeEnergy]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync Specific Data Type
|
||||||
|
|
||||||
|
func syncDataType(_ dataType: HealthDataType, for date: Date = Date()) async throws {
|
||||||
|
try await performSync(for: date, dataTypes: [dataType])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Manual Conflict Resolution
|
||||||
|
|
||||||
|
func resolveConflict(_ conflict: Conflict, selectedReadingId: UUID) async throws {
|
||||||
|
guard let resolution = mergeEngine.resolveConflictManually(conflict, selectedReadingId: selectedReadingId) else {
|
||||||
|
throw SyncError.resolutionFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedConflict = conflict
|
||||||
|
resolvedConflict.status = .resolved
|
||||||
|
resolvedConflict.resolution = resolution
|
||||||
|
resolvedConflict.resolvedAt = Date()
|
||||||
|
|
||||||
|
let mergedRecord = mergeEngine.createMergedRecord(from: resolvedConflict, resolution: resolution)
|
||||||
|
|
||||||
|
// Write the record
|
||||||
|
_ = try await dataWriter.writeRecord(mergedRecord)
|
||||||
|
|
||||||
|
// Remove from pending
|
||||||
|
pendingConflicts.removeAll { $0.id == conflict.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
func ignoreConflict(_ conflict: Conflict) {
|
||||||
|
pendingConflicts.removeAll { $0.id == conflict.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync History
|
||||||
|
|
||||||
|
private func loadSyncHistory() {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: syncHistoryKey),
|
||||||
|
let history = try? JSONDecoder().decode([SyncResult].self, from: data) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
syncHistory = history
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addToHistory(_ result: SyncResult) {
|
||||||
|
syncHistory.insert(result, at: 0)
|
||||||
|
if syncHistory.count > maxHistoryItems {
|
||||||
|
syncHistory = Array(syncHistory.prefix(maxHistoryItems))
|
||||||
|
}
|
||||||
|
saveSyncHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveSyncHistory() {
|
||||||
|
guard let data = try? JSONEncoder().encode(syncHistory) else { return }
|
||||||
|
UserDefaults.standard.set(data, forKey: syncHistoryKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearHistory() {
|
||||||
|
syncHistory.removeAll()
|
||||||
|
UserDefaults.standard.removeObject(forKey: syncHistoryKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notifications
|
||||||
|
|
||||||
|
private func sendConflictNotification(count: Int) async {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
|
||||||
|
let settings = await center.notificationSettings()
|
||||||
|
guard settings.authorizationStatus == .authorized else { return }
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "HealthBridge"
|
||||||
|
content.body = count == 1
|
||||||
|
? "1 Konflikt erfordert Ihre Aufmerksamkeit"
|
||||||
|
: "\(count) Konflikte erfordern Ihre Aufmerksamkeit"
|
||||||
|
content.sound = .default
|
||||||
|
content.badge = NSNumber(value: count)
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: "healthbridge.conflicts",
|
||||||
|
content: content,
|
||||||
|
trigger: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
try? await center.add(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestNotificationPermission() async -> Bool {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try await center.requestAuthorization(options: [.alert, .sound, .badge])
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Statistics
|
||||||
|
|
||||||
|
var todayStats: TodayStats {
|
||||||
|
let today = Calendar.current.startOfDay(for: Date())
|
||||||
|
let todaySyncs = syncHistory.filter {
|
||||||
|
Calendar.current.isDate($0.startedAt, inSameDayAs: today)
|
||||||
|
}
|
||||||
|
|
||||||
|
return TodayStats(
|
||||||
|
syncCount: todaySyncs.count,
|
||||||
|
totalConflicts: todaySyncs.reduce(0) { $0 + $1.totalConflicts },
|
||||||
|
autoResolved: todaySyncs.reduce(0) { $0 + $1.autoResolved },
|
||||||
|
pendingManual: pendingConflicts.count,
|
||||||
|
lastSync: lastSyncDate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting Types
|
||||||
|
|
||||||
|
struct SyncResult: Identifiable, Codable {
|
||||||
|
let id = UUID()
|
||||||
|
let startedAt: Date
|
||||||
|
var completedAt: Date?
|
||||||
|
var status: SyncStatus = .inProgress
|
||||||
|
var totalConflicts = 0
|
||||||
|
var autoResolved = 0
|
||||||
|
var pendingManualReview = 0
|
||||||
|
var writtenRecords = 0
|
||||||
|
var writeErrors = 0
|
||||||
|
var error: String?
|
||||||
|
|
||||||
|
enum SyncStatus: String, Codable {
|
||||||
|
case inProgress = "in_progress"
|
||||||
|
case success = "success"
|
||||||
|
case partialSuccess = "partial_success"
|
||||||
|
case failed = "failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
var duration: TimeInterval? {
|
||||||
|
guard let completed = completedAt else { return nil }
|
||||||
|
return completed.timeIntervalSince(startedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedDuration: String {
|
||||||
|
guard let duration = duration else { return "–" }
|
||||||
|
if duration < 1 {
|
||||||
|
return "< 1s"
|
||||||
|
}
|
||||||
|
return String(format: "%.1fs", duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
var successRate: Double {
|
||||||
|
guard totalConflicts > 0 else { return 1.0 }
|
||||||
|
return Double(autoResolved) / Double(totalConflicts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TodayStats {
|
||||||
|
let syncCount: Int
|
||||||
|
let totalConflicts: Int
|
||||||
|
let autoResolved: Int
|
||||||
|
let pendingManual: Int
|
||||||
|
let lastSync: Date?
|
||||||
|
|
||||||
|
var resolutionRate: Double {
|
||||||
|
guard totalConflicts > 0 else { return 1.0 }
|
||||||
|
return Double(autoResolved) / Double(totalConflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedLastSync: String {
|
||||||
|
guard let date = lastSync else { return "Nie" }
|
||||||
|
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.unitsStyle = .abbreviated
|
||||||
|
return formatter.localizedString(for: date, relativeTo: Date())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SyncError: LocalizedError {
|
||||||
|
case notAuthorized
|
||||||
|
case syncInProgress
|
||||||
|
case resolutionFailed
|
||||||
|
case writeFailed
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .notAuthorized:
|
||||||
|
return "Keine Berechtigung für HealthKit"
|
||||||
|
case .syncInProgress:
|
||||||
|
return "Synchronisierung läuft bereits"
|
||||||
|
case .resolutionFailed:
|
||||||
|
return "Konfliktauflösung fehlgeschlagen"
|
||||||
|
case .writeFailed:
|
||||||
|
return "Schreiben der Daten fehlgeschlagen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Date Extensions
|
||||||
|
extension Date {
|
||||||
|
var startOfDay: Date {
|
||||||
|
Calendar.current.startOfDay(for: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
var endOfDay: Date {
|
||||||
|
Calendar.current.date(byAdding: .day, value: 1, to: startOfDay)!.addingTimeInterval(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isToday: Bool {
|
||||||
|
Calendar.current.isDateInToday(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isYesterday: Bool {
|
||||||
|
Calendar.current.isDateInYesterday(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatted(style: DateFormatter.Style) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = style
|
||||||
|
formatter.timeStyle = .none
|
||||||
|
return formatter.string(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formattedTime() -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .none
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
return formatter.string(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formattedRelative() -> String {
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.unitsStyle = .abbreviated
|
||||||
|
return formatter.localizedString(for: self, relativeTo: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
func adding(days: Int) -> Date {
|
||||||
|
Calendar.current.date(byAdding: .day, value: days, to: self)!
|
||||||
|
}
|
||||||
|
|
||||||
|
func adding(hours: Int) -> Date {
|
||||||
|
Calendar.current.date(byAdding: .hour, value: hours, to: self)!
|
||||||
|
}
|
||||||
|
|
||||||
|
func adding(minutes: Int) -> Date {
|
||||||
|
Calendar.current.date(byAdding: .minute, value: minutes, to: self)!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Double Extensions
|
||||||
|
extension Double {
|
||||||
|
func formatted(decimals: Int = 1) -> String {
|
||||||
|
String(format: "%.\(decimals)f", self)
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedAsInteger: String {
|
||||||
|
String(format: "%.0f", self)
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedAsPercentage: String {
|
||||||
|
String(format: "%.1f%%", self * 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Array Extensions
|
||||||
|
extension Array {
|
||||||
|
func chunked(into size: Int) -> [[Element]] {
|
||||||
|
stride(from: 0, to: count, by: size).map {
|
||||||
|
Array(self[$0..<Swift.min($0 + size, count)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Color Extensions
|
||||||
|
extension Color {
|
||||||
|
static let healthBridgePrimary = Color.blue
|
||||||
|
static let healthBridgeSecondary = Color.cyan
|
||||||
|
static let healthBridgeAccent = Color.orange
|
||||||
|
|
||||||
|
static func forSeverity(_ severity: ConflictSeverity) -> Color {
|
||||||
|
switch severity {
|
||||||
|
case .minor: return .green
|
||||||
|
case .moderate: return .yellow
|
||||||
|
case .significant: return .orange
|
||||||
|
case .major: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func forQuality(_ quality: DataQuality) -> Color {
|
||||||
|
switch quality {
|
||||||
|
case .complete: return .green
|
||||||
|
case .partial: return .yellow
|
||||||
|
case .missing: return .gray
|
||||||
|
case .invalid: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - View Extensions
|
||||||
|
extension View {
|
||||||
|
func cardStyle() -> some View {
|
||||||
|
self
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: 4, y: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sectionHeader(_ title: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(title)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Binding Extensions
|
||||||
|
extension Binding {
|
||||||
|
func onChange(_ handler: @escaping (Value) -> Void) -> Binding<Value> {
|
||||||
|
Binding(
|
||||||
|
get: { self.wrappedValue },
|
||||||
|
set: { newValue in
|
||||||
|
self.wrappedValue = newValue
|
||||||
|
handler(newValue)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Optional Extensions
|
||||||
|
extension Optional where Wrapped == String {
|
||||||
|
var orEmpty: String {
|
||||||
|
self ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var isNilOrEmpty: Bool {
|
||||||
|
self?.isEmpty ?? true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Collection Extensions
|
||||||
|
extension Collection {
|
||||||
|
var isNotEmpty: Bool {
|
||||||
|
!isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class NotificationManager: ObservableObject {
|
||||||
|
static let shared = NotificationManager()
|
||||||
|
|
||||||
|
@Published var isAuthorized = false
|
||||||
|
@Published var pendingNotifications: [String] = []
|
||||||
|
|
||||||
|
private let center = UNUserNotificationCenter.current()
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
Task {
|
||||||
|
await checkAuthorization()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Authorization
|
||||||
|
|
||||||
|
func requestAuthorization() async -> Bool {
|
||||||
|
do {
|
||||||
|
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
|
||||||
|
isAuthorized = granted
|
||||||
|
return granted
|
||||||
|
} catch {
|
||||||
|
print("Notification authorization failed: \(error)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkAuthorization() async {
|
||||||
|
let settings = await center.notificationSettings()
|
||||||
|
isAuthorized = settings.authorizationStatus == .authorized
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Notifications
|
||||||
|
|
||||||
|
func sendConflictNotification(count: Int) async {
|
||||||
|
guard isAuthorized else { return }
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "HealthBridge"
|
||||||
|
|
||||||
|
if count == 1 {
|
||||||
|
content.body = "1 neuer Konflikt erfordert Ihre Aufmerksamkeit"
|
||||||
|
} else {
|
||||||
|
content.body = "\(count) neue Konflikte erfordern Ihre Aufmerksamkeit"
|
||||||
|
}
|
||||||
|
|
||||||
|
content.sound = .default
|
||||||
|
content.badge = NSNumber(value: count)
|
||||||
|
content.categoryIdentifier = "CONFLICT"
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: "healthbridge.conflict.\(Date().timeIntervalSince1970)",
|
||||||
|
content: content,
|
||||||
|
trigger: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await center.add(request)
|
||||||
|
} catch {
|
||||||
|
print("Failed to send notification: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync Notifications
|
||||||
|
|
||||||
|
func sendSyncCompleteNotification(
|
||||||
|
conflictsResolved: Int,
|
||||||
|
pendingConflicts: Int
|
||||||
|
) async {
|
||||||
|
guard isAuthorized else { return }
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Sync abgeschlossen"
|
||||||
|
|
||||||
|
if pendingConflicts > 0 {
|
||||||
|
content.body = "\(conflictsResolved) Konflikte gelöst, \(pendingConflicts) offen"
|
||||||
|
} else {
|
||||||
|
content.body = "Alle \(conflictsResolved) Konflikte wurden gelöst"
|
||||||
|
}
|
||||||
|
|
||||||
|
content.sound = .default
|
||||||
|
content.categoryIdentifier = "SYNC_COMPLETE"
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: "healthbridge.sync.\(Date().timeIntervalSince1970)",
|
||||||
|
content: content,
|
||||||
|
trigger: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await center.add(request)
|
||||||
|
} catch {
|
||||||
|
print("Failed to send notification: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Scheduled Notifications
|
||||||
|
|
||||||
|
func scheduleReminder(at hour: Int, minute: Int) async {
|
||||||
|
guard isAuthorized else { return }
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "HealthBridge Erinnerung"
|
||||||
|
content.body = "Vergessen Sie nicht, Ihre Gesundheitsdaten zu synchronisieren"
|
||||||
|
content.sound = .default
|
||||||
|
|
||||||
|
var dateComponents = DateComponents()
|
||||||
|
dateComponents.hour = hour
|
||||||
|
dateComponents.minute = minute
|
||||||
|
|
||||||
|
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: "healthbridge.reminder.daily",
|
||||||
|
content: content,
|
||||||
|
trigger: trigger
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await center.add(request)
|
||||||
|
} catch {
|
||||||
|
print("Failed to schedule reminder: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelReminder() {
|
||||||
|
center.removePendingNotificationRequests(withIdentifiers: ["healthbridge.reminder.daily"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Badge Management
|
||||||
|
|
||||||
|
func clearBadge() async {
|
||||||
|
do {
|
||||||
|
try await center.setBadgeCount(0)
|
||||||
|
} catch {
|
||||||
|
print("Failed to clear badge: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateBadge(count: Int) async {
|
||||||
|
do {
|
||||||
|
try await center.setBadgeCount(count)
|
||||||
|
} catch {
|
||||||
|
print("Failed to update badge: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notification Categories
|
||||||
|
|
||||||
|
func registerCategories() {
|
||||||
|
// Conflict category with actions
|
||||||
|
let resolveAction = UNNotificationAction(
|
||||||
|
identifier: "RESOLVE_AUTO",
|
||||||
|
title: "Automatisch lösen",
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
|
||||||
|
let viewAction = UNNotificationAction(
|
||||||
|
identifier: "VIEW_CONFLICTS",
|
||||||
|
title: "Anzeigen",
|
||||||
|
options: [.foreground]
|
||||||
|
)
|
||||||
|
|
||||||
|
let conflictCategory = UNNotificationCategory(
|
||||||
|
identifier: "CONFLICT",
|
||||||
|
actions: [resolveAction, viewAction],
|
||||||
|
intentIdentifiers: [],
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sync complete category
|
||||||
|
let syncCategory = UNNotificationCategory(
|
||||||
|
identifier: "SYNC_COMPLETE",
|
||||||
|
actions: [viewAction],
|
||||||
|
intentIdentifiers: [],
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
|
||||||
|
center.setNotificationCategories([conflictCategory, syncCategory])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class DashboardViewModel: ObservableObject {
|
||||||
|
private let syncCoordinator = SyncCoordinator.shared
|
||||||
|
private let dataReader = DataReader.shared
|
||||||
|
private let sourceManager = SourceManager.shared
|
||||||
|
|
||||||
|
@Published var dailySummary: DailySummary?
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var selectedDate = Date()
|
||||||
|
@Published var errorMessage: String?
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
setupBindings()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupBindings() {
|
||||||
|
$selectedDate
|
||||||
|
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
||||||
|
.sink { [weak self] date in
|
||||||
|
Task {
|
||||||
|
await self?.loadData(for: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadData(for date: Date = Date()) async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
dailySummary = try await dataReader.fetchDailySummary(for: date)
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func performSync() async {
|
||||||
|
do {
|
||||||
|
try await syncCoordinator.performSync(for: selectedDate)
|
||||||
|
await loadData(for: selectedDate)
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Sync fehlgeschlagen: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() async {
|
||||||
|
await sourceManager.discoverSources()
|
||||||
|
await loadData(for: selectedDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
var syncStatus: SyncStatus {
|
||||||
|
if syncCoordinator.isSyncing {
|
||||||
|
return .syncing
|
||||||
|
} else if let lastSync = syncCoordinator.lastSyncDate {
|
||||||
|
let hoursSinceSync = Date().timeIntervalSince(lastSync) / 3600
|
||||||
|
if hoursSinceSync < 1 {
|
||||||
|
return .synced
|
||||||
|
} else if hoursSinceSync < 24 {
|
||||||
|
return .stale
|
||||||
|
} else {
|
||||||
|
return .veryStale
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return .neverSynced
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SyncStatus {
|
||||||
|
case syncing
|
||||||
|
case synced
|
||||||
|
case stale
|
||||||
|
case veryStale
|
||||||
|
case neverSynced
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .syncing: return "Synchronisiere..."
|
||||||
|
case .synced: return "Synchronisiert"
|
||||||
|
case .stale: return "Sync empfohlen"
|
||||||
|
case .veryStale: return "Sync überfällig"
|
||||||
|
case .neverSynced: return "Nie synchronisiert"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .syncing: return "arrow.triangle.2.circlepath"
|
||||||
|
case .synced: return "checkmark.circle.fill"
|
||||||
|
case .stale: return "exclamationmark.circle"
|
||||||
|
case .veryStale: return "exclamationmark.triangle"
|
||||||
|
case .neverSynced: return "xmark.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Charts
|
||||||
|
|
||||||
|
struct HealthChart: View {
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let data: [ChartDataPoint]
|
||||||
|
let showConflicts: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: dataType.icon)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
Text(dataType.displayName)
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
if let latest = data.last {
|
||||||
|
Text(latest.formattedValue)
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
Text(dataType.unit)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
Chart(data) { point in
|
||||||
|
LineMark(
|
||||||
|
x: .value("Zeit", point.date),
|
||||||
|
y: .value("Wert", point.value)
|
||||||
|
)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
|
||||||
|
PointMark(
|
||||||
|
x: .value("Zeit", point.date),
|
||||||
|
y: .value("Wert", point.value)
|
||||||
|
)
|
||||||
|
.foregroundStyle(point.hasConflict && showConflicts ? .orange : .blue)
|
||||||
|
.symbolSize(point.hasConflict && showConflicts ? 100 : 50)
|
||||||
|
}
|
||||||
|
.frame(height: 150)
|
||||||
|
.chartXAxis {
|
||||||
|
AxisMarks(values: .stride(by: .hour, count: 4)) { value in
|
||||||
|
AxisValueLabel(format: .dateTime.hour())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartYAxis {
|
||||||
|
AxisMarks(position: .leading)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback for iOS 15
|
||||||
|
simpleChart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var simpleChart: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let maxValue = data.map { $0.value }.max() ?? 1
|
||||||
|
let minValue = data.map { $0.value }.min() ?? 0
|
||||||
|
let range = maxValue - minValue
|
||||||
|
|
||||||
|
Path { path in
|
||||||
|
guard !data.isEmpty else { return }
|
||||||
|
|
||||||
|
let xStep = geometry.size.width / CGFloat(max(1, data.count - 1))
|
||||||
|
|
||||||
|
for (index, point) in data.enumerated() {
|
||||||
|
let x = CGFloat(index) * xStep
|
||||||
|
let normalizedY = range > 0 ? (point.value - minValue) / range : 0.5
|
||||||
|
let y = geometry.size.height * (1 - normalizedY)
|
||||||
|
|
||||||
|
if index == 0 {
|
||||||
|
path.move(to: CGPoint(x: x, y: y))
|
||||||
|
} else {
|
||||||
|
path.addLine(to: CGPoint(x: x, y: y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stroke(Color.blue, lineWidth: 2)
|
||||||
|
}
|
||||||
|
.frame(height: 150)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChartDataPoint: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let date: Date
|
||||||
|
let value: Double
|
||||||
|
let hasConflict: Bool
|
||||||
|
let sourceId: String?
|
||||||
|
|
||||||
|
var formattedValue: String {
|
||||||
|
if value == floor(value) {
|
||||||
|
return String(format: "%.0f", value)
|
||||||
|
}
|
||||||
|
return String(format: "%.1f", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Blood Pressure Chart
|
||||||
|
struct BloodPressureChart: View {
|
||||||
|
let data: [BloodPressurePoint]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "drop.fill")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
Text("Blutdruck")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
if let latest = data.last {
|
||||||
|
Text("\(Int(latest.systolic))/\(Int(latest.diastolic))")
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
Text("mmHg")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
Chart(data) { point in
|
||||||
|
// Systolic
|
||||||
|
LineMark(
|
||||||
|
x: .value("Zeit", point.date),
|
||||||
|
y: .value("Systolisch", point.systolic)
|
||||||
|
)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
|
||||||
|
// Diastolic
|
||||||
|
LineMark(
|
||||||
|
x: .value("Zeit", point.date),
|
||||||
|
y: .value("Diastolisch", point.diastolic)
|
||||||
|
)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
}
|
||||||
|
.frame(height: 150)
|
||||||
|
.chartYScale(domain: 40...180)
|
||||||
|
.chartLegend(position: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BloodPressurePoint: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let date: Date
|
||||||
|
let systolic: Double
|
||||||
|
let diastolic: Double
|
||||||
|
let classification: BloodPressureHandler.BloodPressureClassification
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Summary Ring
|
||||||
|
struct SummaryRing: View {
|
||||||
|
let progress: Double
|
||||||
|
let color: Color
|
||||||
|
let icon: String
|
||||||
|
let value: String
|
||||||
|
let label: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.stroke(color.opacity(0.2), lineWidth: 8)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: min(progress, 1.0))
|
||||||
|
.stroke(color, style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(color)
|
||||||
|
Text(value)
|
||||||
|
.font(.caption2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 60, height: 60)
|
||||||
|
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
HealthChart(
|
||||||
|
dataType: .steps,
|
||||||
|
data: (0..<24).map { hour in
|
||||||
|
ChartDataPoint(
|
||||||
|
date: Calendar.current.date(byAdding: .hour, value: hour, to: Calendar.current.startOfDay(for: Date()))!,
|
||||||
|
value: Double.random(in: 0...1000),
|
||||||
|
hasConflict: hour % 5 == 0,
|
||||||
|
sourceId: nil
|
||||||
|
)
|
||||||
|
},
|
||||||
|
showConflicts: true
|
||||||
|
)
|
||||||
|
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
SummaryRing(
|
||||||
|
progress: 0.75,
|
||||||
|
color: .blue,
|
||||||
|
icon: "figure.walk",
|
||||||
|
value: "7.5k",
|
||||||
|
label: "Schritte"
|
||||||
|
)
|
||||||
|
|
||||||
|
SummaryRing(
|
||||||
|
progress: 0.5,
|
||||||
|
color: .red,
|
||||||
|
icon: "heart.fill",
|
||||||
|
value: "72",
|
||||||
|
label: "Puls"
|
||||||
|
)
|
||||||
|
|
||||||
|
SummaryRing(
|
||||||
|
progress: 1.0,
|
||||||
|
color: .green,
|
||||||
|
icon: "checkmark.circle",
|
||||||
|
value: "100%",
|
||||||
|
label: "Synced"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ConflictsView: View {
|
||||||
|
@EnvironmentObject var syncCoordinator: SyncCoordinator
|
||||||
|
@State private var selectedConflict: Conflict?
|
||||||
|
@State private var showingDetail = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if syncCoordinator.pendingConflicts.isEmpty {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
|
conflictsList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Konflikte")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Menu {
|
||||||
|
Button("Alle automatisch lösen") {
|
||||||
|
Task { await resolveAllAuto() }
|
||||||
|
}
|
||||||
|
Button("Alle ignorieren", role: .destructive) {
|
||||||
|
ignoreAll()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
|
}
|
||||||
|
.disabled(syncCoordinator.pendingConflicts.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $selectedConflict) { conflict in
|
||||||
|
ConflictDetailView(conflict: conflict) { selectedReadingId in
|
||||||
|
Task {
|
||||||
|
await resolveConflict(conflict, selectedReadingId: selectedReadingId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Empty State
|
||||||
|
|
||||||
|
private var emptyState: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 64))
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
|
||||||
|
Text("Keine Konflikte")
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
Text("Alle Ihre Gesundheitsdaten sind synchronisiert")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflicts List
|
||||||
|
|
||||||
|
private var conflictsList: some View {
|
||||||
|
List {
|
||||||
|
ForEach(groupedConflicts.keys.sorted(by: { $0.displayName < $1.displayName }), id: \.self) { dataType in
|
||||||
|
Section(dataType.displayName) {
|
||||||
|
ForEach(groupedConflicts[dataType] ?? []) { conflict in
|
||||||
|
ConflictRow(conflict: conflict)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
selectedConflict = conflict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var groupedConflicts: [HealthDataType: [Conflict]] {
|
||||||
|
Dictionary(grouping: syncCoordinator.pendingConflicts, by: { $0.dataType })
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func resolveConflict(_ conflict: Conflict, selectedReadingId: UUID) async {
|
||||||
|
do {
|
||||||
|
try await syncCoordinator.resolveConflict(conflict, selectedReadingId: selectedReadingId)
|
||||||
|
selectedConflict = nil
|
||||||
|
} catch {
|
||||||
|
print("Failed to resolve conflict: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveAllAuto() async {
|
||||||
|
for conflict in syncCoordinator.pendingConflicts {
|
||||||
|
if let primaryReading = conflict.primarySourceReading {
|
||||||
|
try? await syncCoordinator.resolveConflict(conflict, selectedReadingId: primaryReading.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ignoreAll() {
|
||||||
|
for conflict in syncCoordinator.pendingConflicts {
|
||||||
|
syncCoordinator.ignoreConflict(conflict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Row
|
||||||
|
|
||||||
|
struct ConflictRow: View {
|
||||||
|
let conflict: Conflict
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: conflict.dataType.icon)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
|
||||||
|
Text(conflict.timeWindow.formattedRange)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
severityBadge
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
ForEach(conflict.readings.prefix(3)) { reading in
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(reading.sourceName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(reading.formattedValue)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if conflict.readings.count > 3 {
|
||||||
|
Text("+\(conflict.readings.count - 3) weitere")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var severityBadge: some View {
|
||||||
|
Text(conflict.severity.displayName)
|
||||||
|
.font(.caption2)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(severityColor.opacity(0.2))
|
||||||
|
.foregroundStyle(severityColor)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
private var severityColor: Color {
|
||||||
|
switch conflict.severity {
|
||||||
|
case .minor: return .green
|
||||||
|
case .moderate: return .yellow
|
||||||
|
case .significant: return .orange
|
||||||
|
case .major: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Detail View
|
||||||
|
|
||||||
|
struct ConflictDetailView: View {
|
||||||
|
let conflict: Conflict
|
||||||
|
let onResolve: (UUID) -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var selectedReadingId: UUID?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// Header
|
||||||
|
headerSection
|
||||||
|
|
||||||
|
// Readings
|
||||||
|
readingsSection
|
||||||
|
|
||||||
|
// Difference Info
|
||||||
|
differenceSection
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.navigationTitle("Konflikt lösen")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Abbrechen") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Auswählen") {
|
||||||
|
if let id = selectedReadingId {
|
||||||
|
onResolve(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(selectedReadingId == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
|
||||||
|
private var headerSection: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: conflict.dataType.icon)
|
||||||
|
.font(.largeTitle)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
|
||||||
|
Text(conflict.dataType.displayName)
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
Text(conflict.timeWindow.formattedDate)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text(conflict.timeWindow.formattedRange)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Readings
|
||||||
|
|
||||||
|
private var readingsSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Quellen")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
ForEach(conflict.readings) { reading in
|
||||||
|
ReadingCard(
|
||||||
|
reading: reading,
|
||||||
|
isSelected: selectedReadingId == reading.id,
|
||||||
|
dataType: conflict.dataType
|
||||||
|
)
|
||||||
|
.onTapGesture {
|
||||||
|
selectedReadingId = reading.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Difference
|
||||||
|
|
||||||
|
private var differenceSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Analyse")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("Differenz")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(String(format: "%.1f %@", conflict.valueDifference, conflict.dataType.unit))
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(alignment: .trailing) {
|
||||||
|
Text("Prozentual")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(String(format: "%.1f%%", conflict.percentageDifference))
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reading Card
|
||||||
|
|
||||||
|
struct ReadingCard: View {
|
||||||
|
let reading: SourceReading
|
||||||
|
let isSelected: Bool
|
||||||
|
let dataType: HealthDataType
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: reading.sourceCategory.icon)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
Text(reading.sourceName)
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(reading.sourceCategory.displayName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
|
Text(reading.formattedValue)
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
Text(dataType.unit)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(isSelected ? .blue : .secondary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(isSelected ? Color.blue.opacity(0.1) : Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ConflictsView()
|
||||||
|
.environmentObject(SyncCoordinator.shared)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@EnvironmentObject var appState: AppState
|
||||||
|
@EnvironmentObject var syncCoordinator: SyncCoordinator
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TabView(selection: $appState.selectedTab) {
|
||||||
|
DashboardView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Dashboard", systemImage: "chart.bar.fill")
|
||||||
|
}
|
||||||
|
.tag(AppState.Tab.dashboard)
|
||||||
|
|
||||||
|
ConflictsView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Konflikte", systemImage: "arrow.triangle.2.circlepath")
|
||||||
|
}
|
||||||
|
.tag(AppState.Tab.conflicts)
|
||||||
|
.badge(syncCoordinator.pendingConflicts.count)
|
||||||
|
|
||||||
|
RulesView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Regeln", systemImage: "slider.horizontal.3")
|
||||||
|
}
|
||||||
|
.tag(AppState.Tab.rules)
|
||||||
|
|
||||||
|
SourcesView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Quellen", systemImage: "antenna.radiowaves.left.and.right")
|
||||||
|
}
|
||||||
|
.tag(AppState.Tab.sources)
|
||||||
|
}
|
||||||
|
.tint(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ContentView()
|
||||||
|
.environmentObject(AppState())
|
||||||
|
.environmentObject(SyncCoordinator.shared)
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct DashboardView: View {
|
||||||
|
@EnvironmentObject var appState: AppState
|
||||||
|
@EnvironmentObject var syncCoordinator: SyncCoordinator
|
||||||
|
@StateObject private var dataReader = DataReader.shared
|
||||||
|
|
||||||
|
@State private var dailySummary: DailySummary?
|
||||||
|
@State private var isLoading = false
|
||||||
|
@State private var selectedDate = Date()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// Sync Status Card
|
||||||
|
syncStatusCard
|
||||||
|
|
||||||
|
// Date Picker
|
||||||
|
datePicker
|
||||||
|
|
||||||
|
// Health Metrics
|
||||||
|
if let summary = dailySummary {
|
||||||
|
healthMetricsGrid(summary: summary)
|
||||||
|
} else if isLoading {
|
||||||
|
loadingView
|
||||||
|
} else {
|
||||||
|
emptyStateView
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending Conflicts Alert
|
||||||
|
if !syncCoordinator.pendingConflicts.isEmpty {
|
||||||
|
pendingConflictsCard
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent Sync History
|
||||||
|
recentSyncHistory
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.navigationTitle("HealthBridge")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
Task { await performSync() }
|
||||||
|
} label: {
|
||||||
|
if syncCoordinator.isSyncing {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(syncCoordinator.isSyncing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await loadData()
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await loadData()
|
||||||
|
}
|
||||||
|
.onChange(of: selectedDate) {
|
||||||
|
Task { await loadData() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync Status Card
|
||||||
|
|
||||||
|
private var syncStatusCard: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Heute")
|
||||||
|
.font(.headline)
|
||||||
|
Text(syncCoordinator.todayStats.formattedLastSync)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if syncCoordinator.isSyncing {
|
||||||
|
VStack(alignment: .trailing) {
|
||||||
|
ProgressView(value: syncCoordinator.syncProgress)
|
||||||
|
.frame(width: 60)
|
||||||
|
Text("Synchronisiere...")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
StatItem(
|
||||||
|
value: "\(syncCoordinator.todayStats.syncCount)",
|
||||||
|
label: "Syncs",
|
||||||
|
icon: "arrow.triangle.2.circlepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
StatItem(
|
||||||
|
value: "\(syncCoordinator.todayStats.totalConflicts)",
|
||||||
|
label: "Konflikte",
|
||||||
|
icon: "exclamationmark.triangle"
|
||||||
|
)
|
||||||
|
|
||||||
|
StatItem(
|
||||||
|
value: "\(Int(syncCoordinator.todayStats.resolutionRate * 100))%",
|
||||||
|
label: "Gelöst",
|
||||||
|
icon: "checkmark.circle"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Date Picker
|
||||||
|
|
||||||
|
private var datePicker: some View {
|
||||||
|
DatePicker(
|
||||||
|
"Datum",
|
||||||
|
selection: $selectedDate,
|
||||||
|
in: ...Date(),
|
||||||
|
displayedComponents: .date
|
||||||
|
)
|
||||||
|
.datePickerStyle(.compact)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Health Metrics Grid
|
||||||
|
|
||||||
|
private func healthMetricsGrid(summary: DailySummary) -> some View {
|
||||||
|
LazyVGrid(columns: [
|
||||||
|
GridItem(.flexible()),
|
||||||
|
GridItem(.flexible())
|
||||||
|
], spacing: 16) {
|
||||||
|
ForEach(HealthDataType.allCases) { dataType in
|
||||||
|
if let value = summary.values[dataType], value > 0 {
|
||||||
|
HealthMetricCard(
|
||||||
|
dataType: dataType,
|
||||||
|
value: summary.formattedValue(for: dataType),
|
||||||
|
conflictCount: summary.conflictCounts[dataType] ?? 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pending Conflicts Card
|
||||||
|
|
||||||
|
private var pendingConflictsCard: some View {
|
||||||
|
Button {
|
||||||
|
appState.selectedTab = .conflicts
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("\(syncCoordinator.pendingConflicts.count) Konflikte zu prüfen")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Tippen zum Anzeigen")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.orange.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Recent Sync History
|
||||||
|
|
||||||
|
private var recentSyncHistory: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Letzte Synchronisierungen")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
ForEach(syncCoordinator.syncHistory.prefix(5)) { result in
|
||||||
|
SyncHistoryRow(result: result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if syncCoordinator.syncHistory.isEmpty {
|
||||||
|
Text("Keine Synchronisierungen")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Loading & Empty States
|
||||||
|
|
||||||
|
private var loadingView: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ProgressView()
|
||||||
|
Text("Lade Daten...")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyStateView: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "heart.text.square")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Keine Daten verfügbar")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Synchronisieren Sie, um Daten zu laden")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func loadData() async {
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
dailySummary = try await dataReader.fetchDailySummary(for: selectedDate)
|
||||||
|
} catch {
|
||||||
|
print("Failed to load data: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performSync() async {
|
||||||
|
do {
|
||||||
|
try await syncCoordinator.performSync(for: selectedDate)
|
||||||
|
await loadData()
|
||||||
|
} catch {
|
||||||
|
print("Sync failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting Views
|
||||||
|
|
||||||
|
struct StatItem: View {
|
||||||
|
let value: String
|
||||||
|
let label: String
|
||||||
|
let icon: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(value)
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HealthMetricCard: View {
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let value: String
|
||||||
|
let conflictCount: Int
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: dataType.icon)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
Spacer()
|
||||||
|
if conflictCount > 0 {
|
||||||
|
Text("\(conflictCount)")
|
||||||
|
.font(.caption2)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.orange.opacity(0.2))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(value)
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
Text(dataType.displayName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: 4, y: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SyncHistoryRow: View {
|
||||||
|
let result: SyncResult
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: statusIcon)
|
||||||
|
.foregroundStyle(statusColor)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(formattedDate)
|
||||||
|
.font(.subheadline)
|
||||||
|
Text("\(result.autoResolved)/\(result.totalConflicts) gelöst")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(result.formattedDuration)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusIcon: String {
|
||||||
|
switch result.status {
|
||||||
|
case .success: return "checkmark.circle.fill"
|
||||||
|
case .partialSuccess: return "exclamationmark.circle.fill"
|
||||||
|
case .failed: return "xmark.circle.fill"
|
||||||
|
case .inProgress: return "arrow.clockwise"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusColor: Color {
|
||||||
|
switch result.status {
|
||||||
|
case .success: return .green
|
||||||
|
case .partialSuccess: return .orange
|
||||||
|
case .failed: return .red
|
||||||
|
case .inProgress: return .blue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var formattedDate: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .short
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
return formatter.string(from: result.startedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
DashboardView()
|
||||||
|
.environmentObject(AppState())
|
||||||
|
.environmentObject(SyncCoordinator.shared)
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct RulesView: View {
|
||||||
|
@StateObject private var ruleEngine = RuleEngine.shared
|
||||||
|
@StateObject private var sourceManager = SourceManager.shared
|
||||||
|
@State private var selectedDataType: HealthDataType?
|
||||||
|
@State private var showingResetConfirmation = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
Text("Regeln bestimmen, wie Konflikte zwischen verschiedenen Datenquellen automatisch gelöst werden.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(HealthDataType.allCases) { dataType in
|
||||||
|
RuleRow(
|
||||||
|
dataType: dataType,
|
||||||
|
rule: ruleEngine.getRule(for: dataType)
|
||||||
|
)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
selectedDataType = dataType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.navigationTitle("Regeln")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Menu {
|
||||||
|
Button("Alle zurücksetzen", role: .destructive) {
|
||||||
|
showingResetConfirmation = true
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $selectedDataType) { dataType in
|
||||||
|
RuleEditorView(dataType: dataType)
|
||||||
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
"Alle Regeln zurücksetzen?",
|
||||||
|
isPresented: $showingResetConfirmation,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("Zurücksetzen", role: .destructive) {
|
||||||
|
ruleEngine.resetAllToDefaults()
|
||||||
|
}
|
||||||
|
Button("Abbrechen", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("Alle Regeln werden auf die Standardwerte zurückgesetzt.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rule Row
|
||||||
|
|
||||||
|
struct RuleRow: View {
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let rule: MergeRule
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: dataType.icon)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
.frame(width: 30)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(dataType.displayName)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: rule.strategy.icon)
|
||||||
|
.font(.caption)
|
||||||
|
Text(rule.strategy.displayName)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if !rule.autoApply {
|
||||||
|
Image(systemName: "hand.raised.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rule Editor View
|
||||||
|
|
||||||
|
struct RuleEditorView: View {
|
||||||
|
let dataType: HealthDataType
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@StateObject private var ruleEngine = RuleEngine.shared
|
||||||
|
@StateObject private var sourceManager = SourceManager.shared
|
||||||
|
|
||||||
|
@State private var selectedStrategy: MergeStrategy
|
||||||
|
@State private var autoApply: Bool
|
||||||
|
@State private var primarySourceId: String?
|
||||||
|
@State private var thresholdForManualReview: Double?
|
||||||
|
@State private var useThreshold: Bool
|
||||||
|
|
||||||
|
init(dataType: HealthDataType) {
|
||||||
|
self.dataType = dataType
|
||||||
|
let rule = RuleEngine.shared.getRule(for: dataType)
|
||||||
|
_selectedStrategy = State(initialValue: rule.strategy)
|
||||||
|
_autoApply = State(initialValue: rule.autoApply)
|
||||||
|
_primarySourceId = State(initialValue: rule.primarySourceId)
|
||||||
|
_thresholdForManualReview = State(initialValue: rule.thresholdForManualReview)
|
||||||
|
_useThreshold = State(initialValue: rule.thresholdForManualReview != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
// Data Type Info
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: dataType.icon)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(dataType.displayName)
|
||||||
|
.font(.headline)
|
||||||
|
Text(dataType.unit)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy Selection
|
||||||
|
Section("Strategie") {
|
||||||
|
Picker("Merge-Strategie", selection: $selectedStrategy) {
|
||||||
|
ForEach(MergeStrategy.allCases) { strategy in
|
||||||
|
Label(strategy.displayName, systemImage: strategy.icon)
|
||||||
|
.tag(strategy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.navigationLink)
|
||||||
|
|
||||||
|
Text(selectedStrategy.description)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primary Source (for exclusive/priority)
|
||||||
|
if selectedStrategy == .exclusive || selectedStrategy == .priority {
|
||||||
|
Section("Primäre Quelle") {
|
||||||
|
let sources = sourceManager.sources.filter {
|
||||||
|
$0.supportedDataTypes.contains(dataType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sources.isEmpty {
|
||||||
|
Text("Keine Quellen für diesen Datentyp")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Picker("Quelle", selection: $primarySourceId) {
|
||||||
|
Text("Automatisch").tag(nil as String?)
|
||||||
|
ForEach(sources) { source in
|
||||||
|
Text(source.displayName).tag(source.bundleIdentifier as String?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto Apply
|
||||||
|
Section("Automatisierung") {
|
||||||
|
Toggle("Automatisch anwenden", isOn: $autoApply)
|
||||||
|
|
||||||
|
if autoApply {
|
||||||
|
Toggle("Schwellenwert für manuelle Prüfung", isOn: $useThreshold)
|
||||||
|
|
||||||
|
if useThreshold {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("Bei Differenz über \(Int(thresholdForManualReview ?? 20))% nachfragen")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value: Binding(
|
||||||
|
get: { thresholdForManualReview ?? 20 },
|
||||||
|
set: { thresholdForManualReview = $0 }
|
||||||
|
),
|
||||||
|
in: 5...50,
|
||||||
|
step: 5
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
Section {
|
||||||
|
Button("Auf Standard zurücksetzen", role: .destructive) {
|
||||||
|
let defaultRule = MergeRule.defaultRule(for: dataType)
|
||||||
|
selectedStrategy = defaultRule.strategy
|
||||||
|
autoApply = defaultRule.autoApply
|
||||||
|
primarySourceId = defaultRule.primarySourceId
|
||||||
|
thresholdForManualReview = defaultRule.thresholdForManualReview
|
||||||
|
useThreshold = defaultRule.thresholdForManualReview != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Regel bearbeiten")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Abbrechen") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Speichern") {
|
||||||
|
saveRule()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveRule() {
|
||||||
|
let rule = MergeRule(
|
||||||
|
dataType: dataType,
|
||||||
|
strategy: selectedStrategy,
|
||||||
|
primarySourceId: primarySourceId,
|
||||||
|
autoApply: autoApply,
|
||||||
|
thresholdForManualReview: useThreshold ? thresholdForManualReview : nil
|
||||||
|
)
|
||||||
|
ruleEngine.setRule(rule, for: dataType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
RulesView()
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@AppStorage("backgroundSyncEnabled") private var backgroundSyncEnabled = true
|
||||||
|
@AppStorage("syncIntervalMinutes") private var syncIntervalMinutes = 15
|
||||||
|
@AppStorage("notificationsEnabled") private var notificationsEnabled = true
|
||||||
|
@AppStorage("notifyOnConflict") private var notifyOnConflict = true
|
||||||
|
@AppStorage("notifyOnSyncComplete") private var notifyOnSyncComplete = false
|
||||||
|
@AppStorage("autoResolveMinorConflicts") private var autoResolveMinorConflicts = true
|
||||||
|
|
||||||
|
@StateObject private var syncCoordinator = SyncCoordinator.shared
|
||||||
|
@StateObject private var healthKitManager = HealthKitManager.shared
|
||||||
|
|
||||||
|
@State private var showingClearDataConfirmation = false
|
||||||
|
@State private var showingExportSheet = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
// Sync Settings
|
||||||
|
Section("Synchronisierung") {
|
||||||
|
Toggle("Hintergrund-Sync", isOn: $backgroundSyncEnabled)
|
||||||
|
|
||||||
|
if backgroundSyncEnabled {
|
||||||
|
Picker("Intervall", selection: $syncIntervalMinutes) {
|
||||||
|
Text("15 Minuten").tag(15)
|
||||||
|
Text("30 Minuten").tag(30)
|
||||||
|
Text("1 Stunde").tag(60)
|
||||||
|
Text("2 Stunden").tag(120)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Toggle("Kleine Konflikte automatisch lösen", isOn: $autoResolveMinorConflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification Settings
|
||||||
|
Section("Benachrichtigungen") {
|
||||||
|
Toggle("Benachrichtigungen aktivieren", isOn: $notificationsEnabled)
|
||||||
|
|
||||||
|
if notificationsEnabled {
|
||||||
|
Toggle("Bei neuen Konflikten", isOn: $notifyOnConflict)
|
||||||
|
Toggle("Nach Synchronisierung", isOn: $notifyOnSyncComplete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health Status
|
||||||
|
Section("HealthKit Status") {
|
||||||
|
HStack {
|
||||||
|
Text("Autorisierung")
|
||||||
|
Spacer()
|
||||||
|
if healthKitManager.isAuthorized {
|
||||||
|
Label("Erteilt", systemImage: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
} else {
|
||||||
|
Label("Ausstehend", systemImage: "exclamationmark.circle")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !healthKitManager.isAuthorized {
|
||||||
|
Button("HealthKit-Zugriff anfordern") {
|
||||||
|
Task {
|
||||||
|
try? await healthKitManager.requestAuthorization()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
Section("Statistiken") {
|
||||||
|
StatRow(label: "Syncs heute", value: "\(syncCoordinator.todayStats.syncCount)")
|
||||||
|
StatRow(label: "Konflikte heute", value: "\(syncCoordinator.todayStats.totalConflicts)")
|
||||||
|
StatRow(label: "Automatisch gelöst", value: "\(syncCoordinator.todayStats.autoResolved)")
|
||||||
|
StatRow(label: "Auflösungsrate", value: "\(Int(syncCoordinator.todayStats.resolutionRate * 100))%")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data Management
|
||||||
|
Section("Daten") {
|
||||||
|
Button("Sync-Verlauf exportieren") {
|
||||||
|
showingExportSheet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Sync-Verlauf löschen", role: .destructive) {
|
||||||
|
showingClearDataConfirmation = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// About
|
||||||
|
Section("Info") {
|
||||||
|
HStack {
|
||||||
|
Text("Version")
|
||||||
|
Spacer()
|
||||||
|
Text("1.0.0")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Link(destination: URL(string: "https://apple.com/health")!) {
|
||||||
|
HStack {
|
||||||
|
Text("Apple Health")
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "arrow.up.right.square")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Einstellungen")
|
||||||
|
.confirmationDialog(
|
||||||
|
"Sync-Verlauf löschen?",
|
||||||
|
isPresented: $showingClearDataConfirmation,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("Löschen", role: .destructive) {
|
||||||
|
syncCoordinator.clearHistory()
|
||||||
|
}
|
||||||
|
Button("Abbrechen", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("Der gesamte Sync-Verlauf wird gelöscht. Dies kann nicht rückgängig gemacht werden.")
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingExportSheet) {
|
||||||
|
ExportView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stat Row
|
||||||
|
|
||||||
|
struct StatRow: View {
|
||||||
|
let label: String
|
||||||
|
let value: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text(label)
|
||||||
|
Spacer()
|
||||||
|
Text(value)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Export View
|
||||||
|
|
||||||
|
struct ExportView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@StateObject private var syncCoordinator = SyncCoordinator.shared
|
||||||
|
|
||||||
|
@State private var exportFormat: ExportFormat = .json
|
||||||
|
@State private var isExporting = false
|
||||||
|
|
||||||
|
enum ExportFormat: String, CaseIterable {
|
||||||
|
case json = "JSON"
|
||||||
|
case csv = "CSV"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Image(systemName: "square.and.arrow.up")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
|
||||||
|
Text("Daten exportieren")
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
Picker("Format", selection: $exportFormat) {
|
||||||
|
ForEach(ExportFormat.allCases, id: \.self) { format in
|
||||||
|
Text(format.rawValue).tag(format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
Text("\(syncCoordinator.syncHistory.count) Sync-Einträge")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
exportData()
|
||||||
|
} label: {
|
||||||
|
Label("Exportieren", systemImage: "square.and.arrow.up")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.padding()
|
||||||
|
.disabled(isExporting || syncCoordinator.syncHistory.isEmpty)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.navigationTitle("Export")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Abbrechen") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exportData() {
|
||||||
|
isExporting = true
|
||||||
|
|
||||||
|
// In a real app, this would create and share a file
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||||
|
isExporting = false
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
SettingsView()
|
||||||
|
}
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SourcesView: View {
|
||||||
|
@StateObject private var sourceManager = SourceManager.shared
|
||||||
|
@State private var selectedSource: HealthSource?
|
||||||
|
@State private var isRefreshing = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if sourceManager.sources.isEmpty && !sourceManager.isDiscovering {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
|
sourcesList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Quellen")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
Task { await refreshSources() }
|
||||||
|
} label: {
|
||||||
|
if isRefreshing {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isRefreshing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $selectedSource) { source in
|
||||||
|
SourceDetailView(source: source)
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
if sourceManager.sources.isEmpty {
|
||||||
|
await refreshSources()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Empty State
|
||||||
|
|
||||||
|
private var emptyState: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||||
|
.font(.system(size: 64))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text("Keine Quellen gefunden")
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
Text("Verbinden Sie Geräte mit Apple Health")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await refreshSources() }
|
||||||
|
} label: {
|
||||||
|
Label("Aktualisieren", systemImage: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sources List
|
||||||
|
|
||||||
|
private var sourcesList: some View {
|
||||||
|
List {
|
||||||
|
ForEach(groupedSources.keys.sorted(by: { $0.priority > $1.priority }), id: \.self) { category in
|
||||||
|
Section(category.displayName) {
|
||||||
|
ForEach(groupedSources[category] ?? []) { source in
|
||||||
|
SourceRow(source: source)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
selectedSource = source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.refreshable {
|
||||||
|
await refreshSources()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var groupedSources: [SourceCategory: [HealthSource]] {
|
||||||
|
Dictionary(grouping: sourceManager.sources, by: { $0.category })
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshSources() async {
|
||||||
|
isRefreshing = true
|
||||||
|
defer { isRefreshing = false }
|
||||||
|
await sourceManager.discoverSources()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Row
|
||||||
|
|
||||||
|
struct SourceRow: View {
|
||||||
|
let source: HealthSource
|
||||||
|
@StateObject private var sourceManager = SourceManager.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: source.category.icon)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
.frame(width: 40)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(source.displayName)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Text("\(source.supportedDataTypes.count) Datentypen")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let status = sourceManager.sourceHealthStatus[source.id] {
|
||||||
|
Image(systemName: status.syncStatus.icon)
|
||||||
|
.foregroundStyle(statusColor(for: status.syncStatus))
|
||||||
|
}
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusColor(for status: SourceHealthStatus.SyncStatus) -> Color {
|
||||||
|
switch status {
|
||||||
|
case .recentlySynced: return .green
|
||||||
|
case .syncedToday: return .blue
|
||||||
|
case .stale: return .orange
|
||||||
|
case .veryStale, .neverSynced: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Detail View
|
||||||
|
|
||||||
|
struct SourceDetailView: View {
|
||||||
|
let source: HealthSource
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@StateObject private var sourceManager = SourceManager.shared
|
||||||
|
@State private var healthReport: SourceHealthReport?
|
||||||
|
@State private var isLoading = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// Header
|
||||||
|
headerSection
|
||||||
|
|
||||||
|
// Capabilities
|
||||||
|
capabilitiesSection
|
||||||
|
|
||||||
|
// Data Types
|
||||||
|
dataTypesSection
|
||||||
|
|
||||||
|
// Health Report
|
||||||
|
if let report = healthReport {
|
||||||
|
healthReportSection(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority Settings
|
||||||
|
prioritySection
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.navigationTitle(source.displayName)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Fertig") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await loadHealthReport()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
|
||||||
|
private var headerSection: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: source.category.icon)
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
|
||||||
|
Text(source.displayName)
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
Text(source.category.displayName)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text(source.bundleIdentifier)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Capabilities
|
||||||
|
|
||||||
|
private var capabilitiesSection: some View {
|
||||||
|
let capabilities = sourceManager.getSourceCapabilities(source)
|
||||||
|
|
||||||
|
return VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Fähigkeiten")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: 8) {
|
||||||
|
CapabilityBadge(name: "Schritte", available: capabilities.canMeasureSteps)
|
||||||
|
CapabilityBadge(name: "Herzfrequenz", available: capabilities.canMeasureHeartRate)
|
||||||
|
CapabilityBadge(name: "Blutdruck", available: capabilities.canMeasureBloodPressure)
|
||||||
|
CapabilityBadge(name: "SpO2", available: capabilities.canMeasureBloodOxygen)
|
||||||
|
CapabilityBadge(name: "Schlaf", available: capabilities.canMeasureSleep)
|
||||||
|
CapabilityBadge(name: "GPS", available: capabilities.hasGPS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Types
|
||||||
|
|
||||||
|
private var dataTypesSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Unterstützte Datentypen")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
ForEach(Array(source.supportedDataTypes).sorted(by: { $0.displayName < $1.displayName }), id: \.self) { dataType in
|
||||||
|
HStack {
|
||||||
|
Image(systemName: dataType.icon)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
.frame(width: 24)
|
||||||
|
Text(dataType.displayName)
|
||||||
|
Spacer()
|
||||||
|
Text(dataType.unit)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Health Report
|
||||||
|
|
||||||
|
private func healthReportSection(_ report: SourceHealthReport) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Zustand")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("Datensätze (24h)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("\(report.totalRecordCount)")
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(alignment: .trailing) {
|
||||||
|
Text("Qualität")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Image(systemName: report.overallQuality.icon)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(qualityColor(report.overallQuality))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let lastActivity = report.lastOverallActivity {
|
||||||
|
HStack {
|
||||||
|
Text("Letzte Aktivität")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(formattedDate(lastActivity))
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if report.hasSignificantGaps {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
Text("Datenlücken erkannt")
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Priority Section
|
||||||
|
|
||||||
|
private var prioritySection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Priorität")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Text("Höhere Priorität bedeutet, dass Daten dieser Quelle bevorzugt werden")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
ForEach(Array(source.supportedDataTypes).sorted(by: { $0.displayName < $1.displayName }), id: \.self) { dataType in
|
||||||
|
HStack {
|
||||||
|
Text(dataType.displayName)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Stepper(
|
||||||
|
"\(sourceManager.getPriority(for: source, dataType: dataType))",
|
||||||
|
value: Binding(
|
||||||
|
get: { sourceManager.getPriority(for: source, dataType: dataType) },
|
||||||
|
set: { sourceManager.setPriority($0, for: source, dataType: dataType) }
|
||||||
|
),
|
||||||
|
in: 0...100,
|
||||||
|
step: 10
|
||||||
|
)
|
||||||
|
.frame(width: 150)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func loadHealthReport() async {
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
healthReport = await sourceManager.getSourceHealth(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func qualityColor(_ quality: DataQuality) -> Color {
|
||||||
|
switch quality {
|
||||||
|
case .complete: return .green
|
||||||
|
case .partial: return .yellow
|
||||||
|
case .missing: return .gray
|
||||||
|
case .invalid: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formattedDate(_ date: Date) -> String {
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.unitsStyle = .abbreviated
|
||||||
|
return formatter.localizedString(for: date, relativeTo: Date())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Capability Badge
|
||||||
|
|
||||||
|
struct CapabilityBadge: View {
|
||||||
|
let name: String
|
||||||
|
let available: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: available ? "checkmark.circle.fill" : "xmark.circle")
|
||||||
|
.foregroundStyle(available ? .green : .secondary)
|
||||||
|
Text(name)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(available ? .primary : .secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(available ? Color.green.opacity(0.1) : Color.gray.opacity(0.1))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
SourcesView()
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de" data-lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PromoMaster - Produkt-Promotion-Tool | Product Promotion Tool</title>
|
||||||
|
|
||||||
|
<!-- SEO Meta Tags -->
|
||||||
|
<meta name="description" content="Erstellen Sie automatisch professionelle Produktwerbung in Deutsch und Englisch. Generate professional product promotions in German and English.">
|
||||||
|
<meta name="keywords" content="Produktwerbung, Product Promotion, Marketing, Werbung, Advertising, Social Media, SEO, Product Launch">
|
||||||
|
<meta name="author" content="PromoMaster">
|
||||||
|
<meta name="robots" content="index, follow">
|
||||||
|
|
||||||
|
<!-- Open Graph / Facebook -->
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:title" content="PromoMaster - Automatische Produktwerbung">
|
||||||
|
<meta property="og:description" content="Erstellen Sie automatisch professionelle Produktwerbung in Deutsch und Englisch.">
|
||||||
|
<meta property="og:locale" content="de_DE">
|
||||||
|
<meta property="og:locale:alternate" content="en_US">
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="PromoMaster - Product Promotion Tool">
|
||||||
|
<meta name="twitter:description" content="Generate professional product promotions in German and English automatically.">
|
||||||
|
|
||||||
|
<!-- JSON-LD Structured Data -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebApplication",
|
||||||
|
"name": "PromoMaster",
|
||||||
|
"description": "Automatisches Produkt-Promotion-Tool in Deutsch und Englisch",
|
||||||
|
"applicationCategory": "Marketing",
|
||||||
|
"operatingSystem": "Web Browser",
|
||||||
|
"offers": {
|
||||||
|
"@type": "Offer",
|
||||||
|
"price": "0",
|
||||||
|
"priceCurrency": "EUR"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Language Toggle -->
|
||||||
|
<div class="lang-toggle">
|
||||||
|
<button id="btn-de" class="lang-btn active" onclick="setLanguage('de')">DE</button>
|
||||||
|
<button id="btn-en" class="lang-btn" onclick="setLanguage('en')">EN</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<header class="hero">
|
||||||
|
<div class="hero-bg"></div>
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="hero-title">
|
||||||
|
<span class="hero-icon">⚡</span>
|
||||||
|
PromoMaster
|
||||||
|
</h1>
|
||||||
|
<p class="hero-subtitle" data-de="Dein Produkt. Weltweit bekannt. Vollautomatisch." data-en="Your Product. Worldwide Fame. Fully Automatic.">Dein Produkt. Weltweit bekannt. Vollautomatisch.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main App -->
|
||||||
|
<main class="container">
|
||||||
|
<!-- Step 1: Product Input -->
|
||||||
|
<section class="card step-card" id="step1">
|
||||||
|
<div class="step-header">
|
||||||
|
<span class="step-number">1</span>
|
||||||
|
<h2 data-de="Produkt-Informationen" data-en="Product Information"></h2>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="productName" data-de="Produktname *" data-en="Product Name *"></label>
|
||||||
|
<input type="text" id="productName" data-de-placeholder="z.B. SuperWidget Pro" data-en-placeholder="e.g. SuperWidget Pro">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="productCategory" data-de="Kategorie" data-en="Category"></label>
|
||||||
|
<select id="productCategory">
|
||||||
|
<option value="tech" data-de="Technologie" data-en="Technology"></option>
|
||||||
|
<option value="fashion" data-de="Mode & Bekleidung" data-en="Fashion & Apparel"></option>
|
||||||
|
<option value="food" data-de="Lebensmittel & Getränke" data-en="Food & Beverages"></option>
|
||||||
|
<option value="health" data-de="Gesundheit & Wellness" data-en="Health & Wellness"></option>
|
||||||
|
<option value="home" data-de="Haus & Garten" data-en="Home & Garden"></option>
|
||||||
|
<option value="sport" data-de="Sport & Fitness" data-en="Sports & Fitness"></option>
|
||||||
|
<option value="beauty" data-de="Schönheit & Pflege" data-en="Beauty & Care"></option>
|
||||||
|
<option value="education" data-de="Bildung & Kurse" data-en="Education & Courses"></option>
|
||||||
|
<option value="software" data-de="Software & Apps" data-en="Software & Apps"></option>
|
||||||
|
<option value="other" data-de="Sonstiges" data-en="Other"></option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label for="productDescription" data-de="Kurzbeschreibung *" data-en="Short Description *"></label>
|
||||||
|
<textarea id="productDescription" rows="3" data-de-placeholder="Was macht dein Produkt besonders? (max. 200 Zeichen)" data-en-placeholder="What makes your product special? (max. 200 characters)" maxlength="200"></textarea>
|
||||||
|
<span class="char-count"><span id="charCount">0</span>/200</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="productPrice" data-de="Preis (optional)" data-en="Price (optional)"></label>
|
||||||
|
<input type="text" id="productPrice" data-de-placeholder="z.B. 29,99 EUR" data-en-placeholder="e.g. $29.99">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="productUrl" data-de="Website / Link (optional)" data-en="Website / Link (optional)"></label>
|
||||||
|
<input type="url" id="productUrl" data-de-placeholder="https://dein-produkt.de" data-en-placeholder="https://your-product.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label for="productFeatures" data-de="Top-Features (kommagetrennt)" data-en="Top Features (comma-separated)"></label>
|
||||||
|
<input type="text" id="productFeatures" data-de-placeholder="z.B. Schnell, Zuverlässig, Einfach zu bedienen" data-en-placeholder="e.g. Fast, Reliable, Easy to use">
|
||||||
|
</div>
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label for="targetAudience" data-de="Zielgruppe" data-en="Target Audience"></label>
|
||||||
|
<input type="text" id="targetAudience" data-de-placeholder="z.B. Unternehmer, Studenten, Eltern" data-en-placeholder="e.g. Entrepreneurs, Students, Parents">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Step 2: Promotion Style -->
|
||||||
|
<section class="card step-card" id="step2">
|
||||||
|
<div class="step-header">
|
||||||
|
<span class="step-number">2</span>
|
||||||
|
<h2 data-de="Werbestil wählen" data-en="Choose Promotion Style"></h2>
|
||||||
|
</div>
|
||||||
|
<div class="style-grid">
|
||||||
|
<div class="style-option selected" data-style="professional" onclick="selectStyle(this)">
|
||||||
|
<span class="style-icon">💼</span>
|
||||||
|
<span class="style-label" data-de="Professionell" data-en="Professional"></span>
|
||||||
|
</div>
|
||||||
|
<div class="style-option" data-style="casual" onclick="selectStyle(this)">
|
||||||
|
<span class="style-icon">👋</span>
|
||||||
|
<span class="style-label" data-de="Locker & Freundlich" data-en="Casual & Friendly"></span>
|
||||||
|
</div>
|
||||||
|
<div class="style-option" data-style="urgent" onclick="selectStyle(this)">
|
||||||
|
<span class="style-icon">⚡</span>
|
||||||
|
<span class="style-label" data-de="Dringend & FOMO" data-en="Urgent & FOMO"></span>
|
||||||
|
</div>
|
||||||
|
<div class="style-option" data-style="luxury" onclick="selectStyle(this)">
|
||||||
|
<span class="style-icon">✨</span>
|
||||||
|
<span class="style-label" data-de="Premium & Luxus" data-en="Premium & Luxury"></span>
|
||||||
|
</div>
|
||||||
|
<div class="style-option" data-style="fun" onclick="selectStyle(this)">
|
||||||
|
<span class="style-icon">🎉</span>
|
||||||
|
<span class="style-label" data-de="Spaßig & Kreativ" data-en="Fun & Creative"></span>
|
||||||
|
</div>
|
||||||
|
<div class="style-option" data-style="minimal" onclick="selectStyle(this)">
|
||||||
|
<span class="style-icon">◯</span>
|
||||||
|
<span class="style-label" data-de="Minimalistisch" data-en="Minimalist"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Generate Button -->
|
||||||
|
<div class="generate-section">
|
||||||
|
<button class="btn-generate" onclick="generateAll()" id="generateBtn">
|
||||||
|
<span class="btn-icon">⚡</span>
|
||||||
|
<span data-de="Alle Werbematerialien generieren" data-en="Generate All Promotion Materials"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results Section -->
|
||||||
|
<section id="results" class="results-section hidden">
|
||||||
|
<!-- Social Media Posts -->
|
||||||
|
<div class="card result-card">
|
||||||
|
<div class="result-header">
|
||||||
|
<h3 data-de="📱 Social Media Posts" data-en="📱 Social Media Posts"></h3>
|
||||||
|
<button class="btn-copy-all" onclick="copyAll('social')" data-de="Alle kopieren" data-en="Copy All"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="result-tabs">
|
||||||
|
<button class="tab-btn active" onclick="switchTab(this, 'twitter')">Twitter/X</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab(this, 'instagram')">Instagram</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab(this, 'facebook')">Facebook</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab(this, 'linkedin')">LinkedIn</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab(this, 'tiktok')">TikTok</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content" id="tab-twitter">
|
||||||
|
<div class="lang-results">
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge">DE</span>
|
||||||
|
<div class="result-text" id="twitter-de"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('twitter-de')">📋</button>
|
||||||
|
</div>
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge en">EN</span>
|
||||||
|
<div class="result-text" id="twitter-en"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('twitter-en')">📋</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-content hidden" id="tab-instagram">
|
||||||
|
<div class="lang-results">
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge">DE</span>
|
||||||
|
<div class="result-text" id="instagram-de"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('instagram-de')">📋</button>
|
||||||
|
</div>
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge en">EN</span>
|
||||||
|
<div class="result-text" id="instagram-en"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('instagram-en')">📋</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-content hidden" id="tab-facebook">
|
||||||
|
<div class="lang-results">
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge">DE</span>
|
||||||
|
<div class="result-text" id="facebook-de"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('facebook-de')">📋</button>
|
||||||
|
</div>
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge en">EN</span>
|
||||||
|
<div class="result-text" id="facebook-en"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('facebook-en')">📋</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-content hidden" id="tab-linkedin">
|
||||||
|
<div class="lang-results">
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge">DE</span>
|
||||||
|
<div class="result-text" id="linkedin-de"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('linkedin-de')">📋</button>
|
||||||
|
</div>
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge en">EN</span>
|
||||||
|
<div class="result-text" id="linkedin-en"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('linkedin-en')">📋</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-content hidden" id="tab-tiktok">
|
||||||
|
<div class="lang-results">
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge">DE</span>
|
||||||
|
<div class="result-text" id="tiktok-de"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('tiktok-de')">📋</button>
|
||||||
|
</div>
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge en">EN</span>
|
||||||
|
<div class="result-text" id="tiktok-en"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('tiktok-en')">📋</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email Marketing -->
|
||||||
|
<div class="card result-card">
|
||||||
|
<div class="result-header">
|
||||||
|
<h3 data-de="✉ E-Mail Marketing" data-en="✉ Email Marketing"></h3>
|
||||||
|
<button class="btn-copy-all" onclick="copyAll('email')" data-de="Alle kopieren" data-en="Copy All"></button>
|
||||||
|
</div>
|
||||||
|
<div class="lang-results">
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge">DE</span>
|
||||||
|
<div class="result-text" id="email-de"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('email-de')">📋</button>
|
||||||
|
</div>
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge en">EN</span>
|
||||||
|
<div class="result-text" id="email-en"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('email-en')">📋</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SEO Texts -->
|
||||||
|
<div class="card result-card">
|
||||||
|
<div class="result-header">
|
||||||
|
<h3 data-de="🔎 SEO-Texte & Metadaten" data-en="🔎 SEO Texts & Metadata"></h3>
|
||||||
|
<button class="btn-copy-all" onclick="copyAll('seo')" data-de="Alle kopieren" data-en="Copy All"></button>
|
||||||
|
</div>
|
||||||
|
<div class="lang-results">
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge">DE</span>
|
||||||
|
<div class="result-text" id="seo-de"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('seo-de')">📋</button>
|
||||||
|
</div>
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge en">EN</span>
|
||||||
|
<div class="result-text" id="seo-en"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('seo-en')">📋</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Press Release -->
|
||||||
|
<div class="card result-card">
|
||||||
|
<div class="result-header">
|
||||||
|
<h3 data-de="📰 Pressemitteilung" data-en="📰 Press Release"></h3>
|
||||||
|
<button class="btn-copy-all" onclick="copyAll('press')" data-de="Alle kopieren" data-en="Copy All"></button>
|
||||||
|
</div>
|
||||||
|
<div class="lang-results">
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge">DE</span>
|
||||||
|
<div class="result-text" id="press-de"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('press-de')">📋</button>
|
||||||
|
</div>
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge en">EN</span>
|
||||||
|
<div class="result-text" id="press-en"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('press-en')">📋</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Slogan Generator -->
|
||||||
|
<div class="card result-card">
|
||||||
|
<div class="result-header">
|
||||||
|
<h3 data-de="💡 Slogans & Taglines" data-en="💡 Slogans & Taglines"></h3>
|
||||||
|
</div>
|
||||||
|
<div class="slogan-grid" id="sloganGrid"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hashtag Cloud -->
|
||||||
|
<div class="card result-card">
|
||||||
|
<div class="result-header">
|
||||||
|
<h3 data-de="# Hashtag-Wolke" data-en="# Hashtag Cloud"></h3>
|
||||||
|
<button class="btn-copy-all" onclick="copyHashtags()" data-de="Alle kopieren" data-en="Copy All"></button>
|
||||||
|
</div>
|
||||||
|
<div class="hashtag-cloud" id="hashtagCloud"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Landing Page Preview -->
|
||||||
|
<div class="card result-card">
|
||||||
|
<div class="result-header">
|
||||||
|
<h3 data-de="🌐 Landing Page HTML" data-en="🌐 Landing Page HTML"></h3>
|
||||||
|
<button class="btn-copy-all" onclick="copyText('landing-code')" data-de="HTML kopieren" data-en="Copy HTML"></button>
|
||||||
|
</div>
|
||||||
|
<div class="landing-preview-container">
|
||||||
|
<div class="preview-toggle">
|
||||||
|
<button class="preview-btn active" onclick="togglePreview('preview')" data-de="Vorschau" data-en="Preview"></button>
|
||||||
|
<button class="preview-btn" onclick="togglePreview('code')" data-de="HTML-Code" data-en="HTML Code"></button>
|
||||||
|
</div>
|
||||||
|
<div id="landing-preview" class="landing-preview"></div>
|
||||||
|
<pre id="landing-code" class="landing-code hidden"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Export Section -->
|
||||||
|
<div class="card result-card export-card">
|
||||||
|
<div class="result-header">
|
||||||
|
<h3 data-de="📥 Alles exportieren" data-en="📥 Export Everything"></h3>
|
||||||
|
</div>
|
||||||
|
<div class="export-grid">
|
||||||
|
<button class="btn-export" onclick="exportAs('txt')">
|
||||||
|
<span class="export-icon">📄</span>
|
||||||
|
<span>TXT</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn-export" onclick="exportAs('html')">
|
||||||
|
<span class="export-icon">🌐</span>
|
||||||
|
<span>HTML</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn-export" onclick="exportAs('json')">
|
||||||
|
<span class="export-icon">📚</span>
|
||||||
|
<span>JSON</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn-export" onclick="exportAs('csv')">
|
||||||
|
<span class="export-icon">📊</span>
|
||||||
|
<span>CSV</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Toast Notification -->
|
||||||
|
<div class="toast hidden" id="toast"></div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="footer">
|
||||||
|
<p data-de="PromoMaster - Vollautomatisches Produkt-Promotion-Tool" data-en="PromoMaster - Fully Automatic Product Promotion Tool"></p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="promo.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,813 @@
|
|||||||
|
// === PromoMaster - Product Promotion Tool ===
|
||||||
|
// Vollautomatisches Produkt-Promotion-Tool in Deutsch und Englisch
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
let currentLang = 'de';
|
||||||
|
let selectedStyle = 'professional';
|
||||||
|
let generatedData = null;
|
||||||
|
|
||||||
|
// --- Language System ---
|
||||||
|
function setLanguage(lang) {
|
||||||
|
currentLang = lang;
|
||||||
|
document.documentElement.setAttribute('data-lang', lang);
|
||||||
|
document.getElementById('btn-de').classList.toggle('active', lang === 'de');
|
||||||
|
document.getElementById('btn-en').classList.toggle('active', lang === 'en');
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-de]').forEach(function (el) {
|
||||||
|
el.textContent = el.getAttribute('data-' + lang);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-de-placeholder]').forEach(function (el) {
|
||||||
|
el.placeholder = el.getAttribute('data-' + lang + '-placeholder');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('select option').forEach(function (opt) {
|
||||||
|
var val = opt.getAttribute('data-' + lang);
|
||||||
|
if (val) opt.textContent = val;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setLanguage = setLanguage;
|
||||||
|
|
||||||
|
// --- Style Selection ---
|
||||||
|
function selectStyle(el) {
|
||||||
|
document.querySelectorAll('.style-option').forEach(function (s) {
|
||||||
|
s.classList.remove('selected');
|
||||||
|
});
|
||||||
|
el.classList.add('selected');
|
||||||
|
selectedStyle = el.getAttribute('data-style');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.selectStyle = selectStyle;
|
||||||
|
|
||||||
|
// --- Character Counter ---
|
||||||
|
var descInput = document.getElementById('productDescription');
|
||||||
|
var charCount = document.getElementById('charCount');
|
||||||
|
if (descInput && charCount) {
|
||||||
|
descInput.addEventListener('input', function () {
|
||||||
|
charCount.textContent = descInput.value.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Text Templates ---
|
||||||
|
var templates = {
|
||||||
|
professional: {
|
||||||
|
twitter: {
|
||||||
|
de: function (p) {
|
||||||
|
return 'Entdecken Sie ' + p.name + ' \u2013 ' + p.desc + (p.features.length ? '\n\n\u2705 ' + p.features.slice(0, 3).join('\n\u2705 ') : '') + (p.url ? '\n\n\ud83d\udc49 ' + p.url : '') + (p.price ? '\n\ud83d\udcb0 ' + p.price : '') + '\n\n' + p.hashtags.slice(0, 4).join(' ');
|
||||||
|
},
|
||||||
|
en: function (p) {
|
||||||
|
return 'Discover ' + p.name + ' \u2013 ' + p.descEn + (p.features.length ? '\n\n\u2705 ' + p.featuresEn.slice(0, 3).join('\n\u2705 ') : '') + (p.url ? '\n\n\ud83d\udc49 ' + p.url : '') + (p.price ? '\n\ud83d\udcb0 ' + p.price : '') + '\n\n' + p.hashtagsEn.slice(0, 4).join(' ');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
instagram: {
|
||||||
|
de: function (p) {
|
||||||
|
return '\u2728 ' + p.name + ' \u2013 Die Zukunft beginnt jetzt!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Was ' + p.name + ' besonders macht:\n' + p.features.map(function (f) { return '\ud83d\udd39 ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? '\ud83c\udfaf Perfekt f\u00fcr: ' + p.audience + '\n\n' : '') + (p.price ? '\ud83d\udcb0 Jetzt f\u00fcr nur ' + p.price + '\n\n' : '') + (p.url ? '\ud83d\udd17 Link in Bio \u2b06\ufe0f\n\n' : '') + p.hashtags.join(' ');
|
||||||
|
},
|
||||||
|
en: function (p) {
|
||||||
|
return '\u2728 ' + p.name + ' \u2013 The future starts now!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'What makes ' + p.name + ' special:\n' + p.featuresEn.map(function (f) { return '\ud83d\udd39 ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? '\ud83c\udfaf Perfect for: ' + p.audienceEn + '\n\n' : '') + (p.price ? '\ud83d\udcb0 Now only ' + p.price + '\n\n' : '') + (p.url ? '\ud83d\udd17 Link in bio \u2b06\ufe0f\n\n' : '') + p.hashtagsEn.join(' ');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
facebook: {
|
||||||
|
de: function (p) {
|
||||||
|
return '\ud83d\ude80 Neu: ' + p.name + '!\n\n' + p.desc + '\n\n' + (p.features.length ? '\ud83d\udc47 Das sind die Highlights:\n\n' + p.features.map(function (f) { return '\u2714\ufe0f ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? 'Perfekt f\u00fcr alle, die ' + p.audience + ' sind.\n\n' : '') + (p.price ? '\ud83d\udcb5 Preis: ' + p.price + '\n\n' : '') + (p.url ? '\ud83c\udf10 Mehr erfahren: ' + p.url + '\n\n' : '') + 'Was denkt ihr? Lasst es uns in den Kommentaren wissen! \ud83d\udc47\n\n' + p.hashtags.slice(0, 5).join(' ');
|
||||||
|
},
|
||||||
|
en: function (p) {
|
||||||
|
return '\ud83d\ude80 New: ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.features.length ? '\ud83d\udc47 Here are the highlights:\n\n' + p.featuresEn.map(function (f) { return '\u2714\ufe0f ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? 'Perfect for everyone who is ' + p.audienceEn + '.\n\n' : '') + (p.price ? '\ud83d\udcb5 Price: ' + p.price + '\n\n' : '') + (p.url ? '\ud83c\udf10 Learn more: ' + p.url + '\n\n' : '') + 'What do you think? Let us know in the comments! \ud83d\udc47\n\n' + p.hashtagsEn.slice(0, 5).join(' ');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
linkedin: {
|
||||||
|
de: function (p) {
|
||||||
|
return '\ud83d\udca1 ' + p.name + ' \u2013 Innovation trifft Effizienz\n\nIch freue mich, Ihnen ' + p.name + ' vorzustellen.\n\n' + p.desc + '\n\n' + (p.features.length ? 'Die wichtigsten Vorteile:\n\n' + p.features.map(function (f) { return '\u2192 ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? 'Zielgruppe: ' + p.audience + '\n\n' : '') + (p.price ? 'Investition: ' + p.price + '\n\n' : '') + (p.url ? 'Erfahren Sie mehr: ' + p.url + '\n\n' : '') + '#Innovation #Business ' + p.hashtags.slice(0, 3).join(' ');
|
||||||
|
},
|
||||||
|
en: function (p) {
|
||||||
|
return '\ud83d\udca1 ' + p.name + ' \u2013 Innovation meets Efficiency\n\nI\'m excited to introduce ' + p.name + ' to you.\n\n' + p.descEn + '\n\n' + (p.features.length ? 'Key benefits:\n\n' + p.featuresEn.map(function (f) { return '\u2192 ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? 'Target audience: ' + p.audienceEn + '\n\n' : '') + (p.price ? 'Investment: ' + p.price + '\n\n' : '') + (p.url ? 'Learn more: ' + p.url + '\n\n' : '') + '#Innovation #Business ' + p.hashtagsEn.slice(0, 3).join(' ');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tiktok: {
|
||||||
|
de: function (p) {
|
||||||
|
return '\ud83d\udd25 POV: Du entdeckst gerade ' + p.name + '!\n\n' + p.desc + '\n\n' + (p.features.length ? p.features.slice(0, 3).map(function (f) { return '\u2728 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? '\ud83d\udcb0 ' + p.price + '\n\n' : '') + (p.url ? '\ud83d\udd17 Link in Bio!\n\n' : '') + p.hashtags.join(' ') + ' #fyp #viral #musthave';
|
||||||
|
},
|
||||||
|
en: function (p) {
|
||||||
|
return '\ud83d\udd25 POV: You just discovered ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.slice(0, 3).map(function (f) { return '\u2728 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? '\ud83d\udcb0 ' + p.price + '\n\n' : '') + (p.url ? '\ud83d\udd17 Link in bio!\n\n' : '') + p.hashtagsEn.join(' ') + ' #fyp #viral #musthave';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
casual: {
|
||||||
|
twitter: {
|
||||||
|
de: function (p) { return 'Hey Leute! \ud83d\udc4b Kennt ihr schon ' + p.name + '? ' + p.desc + (p.features.length ? '\n\nDas Beste daran:\n' + p.features.slice(0, 3).map(function (f) { return '\ud83d\udc4d ' + f; }).join('\n') : '') + (p.url ? '\n\nSchaut mal vorbei: ' + p.url : '') + '\n\n' + p.hashtags.slice(0, 4).join(' '); },
|
||||||
|
en: function (p) { return 'Hey everyone! \ud83d\udc4b Have you heard of ' + p.name + '? ' + p.descEn + (p.features.length ? '\n\nBest thing about it:\n' + p.featuresEn.slice(0, 3).map(function (f) { return '\ud83d\udc4d ' + f; }).join('\n') : '') + (p.url ? '\n\nCheck it out: ' + p.url : '') + '\n\n' + p.hashtagsEn.slice(0, 4).join(' '); }
|
||||||
|
},
|
||||||
|
instagram: {
|
||||||
|
de: function (p) { return 'Schaut mal was ich gefunden habe! \ud83e\udd29\n\n' + p.name + ' \u2013 ' + p.desc + '\n\n' + (p.features.length ? 'Warum ich es liebe:\n' + p.features.map(function (f) { return '\u2764\ufe0f ' + f; }).join('\n') + '\n\n' : '') + 'Wer will es auch haben? \ud83d\ude4b\u200d\u2640\ufe0f\n\n' + p.hashtags.join(' '); },
|
||||||
|
en: function (p) { return 'Look what I found! \ud83e\udd29\n\n' + p.name + ' \u2013 ' + p.descEn + '\n\n' + (p.features.length ? 'Why I love it:\n' + p.featuresEn.map(function (f) { return '\u2764\ufe0f ' + f; }).join('\n') + '\n\n' : '') + 'Who else wants this? \ud83d\ude4b\u200d\u2640\ufe0f\n\n' + p.hashtagsEn.join(' '); }
|
||||||
|
},
|
||||||
|
facebook: {
|
||||||
|
de: function (p) { return 'Hey Freunde! \ud83d\udc4b\n\nIch muss euch unbedingt von ' + p.name + ' erz\u00e4hlen!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Was mich \u00fcberzeugt hat:\n' + p.features.map(function (f) { return '\ud83d\udc49 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? 'Und das f\u00fcr ' + p.price + ' \u2013 richtig fair!\n\n' : '') + (p.url ? 'Hier gehts lang: ' + p.url + '\n\n' : '') + 'Kennt ihr das schon? \ud83d\ude0d'; },
|
||||||
|
en: function (p) { return 'Hey friends! \ud83d\udc4b\n\nI have to tell you about ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'What convinced me:\n' + p.featuresEn.map(function (f) { return '\ud83d\udc49 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? 'And all that for ' + p.price + ' \u2013 such a great deal!\n\n' : '') + (p.url ? 'Check it here: ' + p.url + '\n\n' : '') + 'Have you heard of this? \ud83d\ude0d'; }
|
||||||
|
},
|
||||||
|
linkedin: {
|
||||||
|
de: function (p) { return 'Moin zusammen! \ud83d\ude4c\n\nDarf ich vorstellen: ' + p.name + '!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Das macht es so cool:\n' + p.features.map(function (f) { return '\u2022 ' + f; }).join('\n') + '\n\n' : '') + 'Wer hat Lust, das mal auszuprobieren?\n\n' + p.hashtags.slice(0, 3).join(' '); },
|
||||||
|
en: function (p) { return 'Hey everyone! \ud83d\ude4c\n\nLet me introduce: ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'What makes it awesome:\n' + p.featuresEn.map(function (f) { return '\u2022 ' + f; }).join('\n') + '\n\n' : '') + 'Who wants to give it a try?\n\n' + p.hashtagsEn.slice(0, 3).join(' '); }
|
||||||
|
},
|
||||||
|
tiktok: {
|
||||||
|
de: function (p) { return 'OK das m\u00fcsst ihr sehen!! \ud83d\ude31\n\n' + p.name + ' ist einfach WILD!\n' + p.desc + '\n\n' + (p.features.length ? p.features.slice(0, 2).map(function (f) { return '\ud83e\udd2f ' + f; }).join('\n') + '\n\n' : '') + 'Kommentiert wenn ihrs auch braucht! \ud83d\udc47\n\n' + p.hashtags.join(' ') + ' #fyp #musthave'; },
|
||||||
|
en: function (p) { return 'OK you NEED to see this!! \ud83d\ude31\n\n' + p.name + ' is absolutely WILD!\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.slice(0, 2).map(function (f) { return '\ud83e\udd2f ' + f; }).join('\n') + '\n\n' : '') + 'Comment if you need this too! \ud83d\udc47\n\n' + p.hashtagsEn.join(' ') + ' #fyp #musthave'; }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
urgent: {
|
||||||
|
twitter: {
|
||||||
|
de: function (p) { return '\u26a0\ufe0f NUR F\u00dcR KURZE ZEIT: ' + p.name + '!\n\n' + p.desc + '\n\n' + (p.price ? '\ud83d\udcb0 Jetzt zuschlagen: ' + p.price + '\n' : '') + '\u23f0 Begrenztes Angebot \u2013 nicht verpassen!\n\n' + (p.url ? '\ud83d\udc49 Sofort sichern: ' + p.url + '\n\n' : '') + p.hashtags.slice(0, 4).join(' '); },
|
||||||
|
en: function (p) { return '\u26a0\ufe0f LIMITED TIME ONLY: ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.price ? '\ud83d\udcb0 Get it now: ' + p.price + '\n' : '') + '\u23f0 Limited offer \u2013 don\'t miss out!\n\n' + (p.url ? '\ud83d\udc49 Grab yours: ' + p.url + '\n\n' : '') + p.hashtagsEn.slice(0, 4).join(' '); }
|
||||||
|
},
|
||||||
|
instagram: {
|
||||||
|
de: function (p) { return '\ud83d\udea8 ACHTUNG! \ud83d\udea8\n\n' + p.name + ' ist DA!\n\n' + p.desc + '\n\n' + (p.features.length ? '\ud83d\udd25 ' + p.features.join(' \u2022 ') + '\n\n' : '') + '\u23f3 Nur solange der Vorrat reicht!\n' + (p.price ? '\ud83d\udcb0 ' + p.price + '\n\n' : '') + 'JETZT HANDELN bevor es zu sp\u00e4t ist! \ud83d\udc47\n\n' + p.hashtags.join(' '); },
|
||||||
|
en: function (p) { return '\ud83d\udea8 ATTENTION! \ud83d\udea8\n\n' + p.name + ' is HERE!\n\n' + p.descEn + '\n\n' + (p.features.length ? '\ud83d\udd25 ' + p.featuresEn.join(' \u2022 ') + '\n\n' : '') + '\u23f3 Only while supplies last!\n' + (p.price ? '\ud83d\udcb0 ' + p.price + '\n\n' : '') + 'ACT NOW before it\'s too late! \ud83d\udc47\n\n' + p.hashtagsEn.join(' '); }
|
||||||
|
},
|
||||||
|
facebook: {
|
||||||
|
de: function (p) { return '\ud83d\udea8\ud83d\udea8\ud83d\udea8 EILMELDUNG \ud83d\udea8\ud83d\udea8\ud83d\udea8\n\n' + p.name + ' ist endlich verf\u00fcgbar!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Die Fakten:\n' + p.features.map(function (f) { return '\u26a1 ' + f; }).join('\n') + '\n\n' : '') + '\u23f0 ACHTUNG: Angebot endet bald!\n' + (p.price ? '\ud83d\udcb5 Nur ' + p.price + '\n' : '') + (p.url ? '\n\ud83d\udc49 SOFORT ZUSCHLAGEN: ' + p.url : ''); },
|
||||||
|
en: function (p) { return '\ud83d\udea8\ud83d\udea8\ud83d\udea8 BREAKING \ud83d\udea8\ud83d\udea8\ud83d\udea8\n\n' + p.name + ' is finally available!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'The facts:\n' + p.featuresEn.map(function (f) { return '\u26a1 ' + f; }).join('\n') + '\n\n' : '') + '\u23f0 WARNING: Offer ends soon!\n' + (p.price ? '\ud83d\udcb5 Only ' + p.price + '\n' : '') + (p.url ? '\n\ud83d\udc49 GRAB IT NOW: ' + p.url : ''); }
|
||||||
|
},
|
||||||
|
linkedin: {
|
||||||
|
de: function (p) { return '\ud83d\udea8 Dringende Marktchance: ' + p.name + '\n\n' + p.desc + '\n\n' + (p.features.length ? 'Schl\u00fcsselvorteile:\n' + p.features.map(function (f) { return '\u2192 ' + f; }).join('\n') + '\n\n' : '') + 'Dieses Angebot ist zeitlich begrenzt. Wer jetzt nicht handelt, verpasst eine einmalige Gelegenheit.\n\n' + (p.url ? p.url + '\n\n' : '') + p.hashtags.slice(0, 3).join(' '); },
|
||||||
|
en: function (p) { return '\ud83d\udea8 Urgent Market Opportunity: ' + p.name + '\n\n' + p.descEn + '\n\n' + (p.features.length ? 'Key advantages:\n' + p.featuresEn.map(function (f) { return '\u2192 ' + f; }).join('\n') + '\n\n' : '') + 'This offer is time-limited. Those who don\'t act now will miss a unique opportunity.\n\n' + (p.url ? p.url + '\n\n' : '') + p.hashtagsEn.slice(0, 3).join(' '); }
|
||||||
|
},
|
||||||
|
tiktok: {
|
||||||
|
de: function (p) { return '\ud83d\udea8 STOP SCROLLING! \ud83d\udea8\n\n' + p.name + ' \u2013 ' + p.desc + '\n\n' + '\u23f0 Letzte Chance!\n' + (p.price ? '\ud83d\udcb0 ' + p.price + '\n' : '') + '\nLINK IN BIO BEVOR ES WEG IST!\n\n' + p.hashtags.join(' ') + ' #fyp #limitedoffer'; },
|
||||||
|
en: function (p) { return '\ud83d\udea8 STOP SCROLLING! \ud83d\udea8\n\n' + p.name + ' \u2013 ' + p.descEn + '\n\n' + '\u23f0 Last chance!\n' + (p.price ? '\ud83d\udcb0 ' + p.price + '\n' : '') + '\nLINK IN BIO BEFORE IT\'S GONE!\n\n' + p.hashtagsEn.join(' ') + ' #fyp #limitedoffer'; }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
luxury: {
|
||||||
|
twitter: {
|
||||||
|
de: function (p) { return '\u2728 ' + p.name + '\n\nExklusivit\u00e4t neu definiert.\n' + p.desc + '\n\n' + (p.price ? 'Ab ' + p.price + '\n' : '') + (p.url ? '\n' + p.url : '') + '\n\n' + p.hashtags.slice(0, 3).join(' ') + ' #Luxus #Premium'; },
|
||||||
|
en: function (p) { return '\u2728 ' + p.name + '\n\nRedefining exclusivity.\n' + p.descEn + '\n\n' + (p.price ? 'From ' + p.price + '\n' : '') + (p.url ? '\n' + p.url : '') + '\n\n' + p.hashtagsEn.slice(0, 3).join(' ') + ' #Luxury #Premium'; }
|
||||||
|
},
|
||||||
|
instagram: {
|
||||||
|
de: function (p) { return '\u2726 ' + p.name.toUpperCase() + ' \u2726\n\n' + p.desc + '\n\n' + (p.features.length ? 'Exklusive Merkmale:\n' + p.features.map(function (f) { return '\u2726 ' + f; }).join('\n') + '\n\n' : '') + 'F\u00fcr alle, die das Beste verdienen.\n\n' + (p.price ? '\u2726 ' + p.price + '\n\n' : '') + p.hashtags.join(' ') + ' #Luxury #Exclusive'; },
|
||||||
|
en: function (p) { return '\u2726 ' + p.name.toUpperCase() + ' \u2726\n\n' + p.descEn + '\n\n' + (p.features.length ? 'Exclusive features:\n' + p.featuresEn.map(function (f) { return '\u2726 ' + f; }).join('\n') + '\n\n' : '') + 'For those who deserve the finest.\n\n' + (p.price ? '\u2726 ' + p.price + '\n\n' : '') + p.hashtagsEn.join(' ') + ' #Luxury #Exclusive'; }
|
||||||
|
},
|
||||||
|
facebook: {
|
||||||
|
de: function (p) { return '\u2014\u2014\u2014 ' + p.name.toUpperCase() + ' \u2014\u2014\u2014\n\n' + p.desc + '\n\n' + (p.features.length ? p.features.map(function (f) { return '\u25c7 ' + f; }).join('\n') + '\n\n' : '') + 'Perfektion kennt keine Kompromisse.\n\n' + (p.price ? 'Ab ' + p.price + '\n' : '') + (p.url ? '\nEntdecken Sie mehr: ' + p.url : ''); },
|
||||||
|
en: function (p) { return '\u2014\u2014\u2014 ' + p.name.toUpperCase() + ' \u2014\u2014\u2014\n\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.map(function (f) { return '\u25c7 ' + f; }).join('\n') + '\n\n' : '') + 'Perfection knows no compromise.\n\n' + (p.price ? 'From ' + p.price + '\n' : '') + (p.url ? '\nDiscover more: ' + p.url : ''); }
|
||||||
|
},
|
||||||
|
linkedin: {
|
||||||
|
de: function (p) { return p.name + ' \u2013 Exzellenz in jeder Hinsicht\n\n' + p.desc + '\n\n' + (p.features.length ? p.features.map(function (f) { return '\u2022 ' + f; }).join('\n') + '\n\n' : '') + 'Wir setzen Ma\u00dfst\u00e4be f\u00fcr Premium-Qualit\u00e4t.\n\n' + (p.url ? p.url + '\n\n' : '') + '#Premium #Excellence ' + p.hashtags.slice(0, 2).join(' '); },
|
||||||
|
en: function (p) { return p.name + ' \u2013 Excellence in every way\n\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.map(function (f) { return '\u2022 ' + f; }).join('\n') + '\n\n' : '') + 'We set the standard for premium quality.\n\n' + (p.url ? p.url + '\n\n' : '') + '#Premium #Excellence ' + p.hashtagsEn.slice(0, 2).join(' '); }
|
||||||
|
},
|
||||||
|
tiktok: {
|
||||||
|
de: function (p) { return '\u2728 ' + p.name + ' \u2013 Luxus der n\u00e4chsten Generation\n\n' + p.desc + '\n\n' + (p.price ? '\u2726 ' + p.price + '\n\n' : '') + p.hashtags.join(' ') + ' #luxury #aesthetic #premium'; },
|
||||||
|
en: function (p) { return '\u2728 ' + p.name + ' \u2013 Next generation luxury\n\n' + p.descEn + '\n\n' + (p.price ? '\u2726 ' + p.price + '\n\n' : '') + p.hashtagsEn.join(' ') + ' #luxury #aesthetic #premium'; }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fun: {
|
||||||
|
twitter: {
|
||||||
|
de: function (p) { return '\ud83c\udf89 YOOO! ' + p.name + ' ist da und es ist der HAMMER! \ud83d\udd28\n\n' + p.desc + '\n\n' + (p.features.length ? p.features.slice(0, 3).map(function (f) { return '\ud83c\udf1f ' + f; }).join('\n') + '\n' : '') + (p.url ? '\n\ud83d\ude80 Ab gehts: ' + p.url : '') + '\n\n' + p.hashtags.slice(0, 4).join(' '); },
|
||||||
|
en: function (p) { return '\ud83c\udf89 YOOO! ' + p.name + ' is here and it\'s AMAZING! \ud83d\udd28\n\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.slice(0, 3).map(function (f) { return '\ud83c\udf1f ' + f; }).join('\n') + '\n' : '') + (p.url ? '\n\ud83d\ude80 Let\'s go: ' + p.url : '') + '\n\n' + p.hashtagsEn.slice(0, 4).join(' '); }
|
||||||
|
},
|
||||||
|
instagram: {
|
||||||
|
de: function (p) { return '\ud83e\udd2f OKAY WOW \ud83e\udd2f\n\n' + p.name + ' hat mein Leben ver\u00e4ndert und ich bin NICHT dramatisch! \ud83d\ude02\n\n' + p.desc + '\n\n' + (p.features.length ? 'Reasons to love it:\n' + p.features.map(function (f) { return '\ud83d\udcab ' + f; }).join('\n') + '\n\n' : '') + 'Wer ist dabei?! \ud83d\ude4b\u200d\u2642\ufe0f\n\n' + p.hashtags.join(' '); },
|
||||||
|
en: function (p) { return '\ud83e\udd2f OKAY WOW \ud83e\udd2f\n\n' + p.name + ' changed my life and I\'m NOT being dramatic! \ud83d\ude02\n\n' + p.descEn + '\n\n' + (p.features.length ? 'Reasons to love it:\n' + p.featuresEn.map(function (f) { return '\ud83d\udcab ' + f; }).join('\n') + '\n\n' : '') + 'Who\'s in?! \ud83d\ude4b\u200d\u2642\ufe0f\n\n' + p.hashtagsEn.join(' '); }
|
||||||
|
},
|
||||||
|
facebook: {
|
||||||
|
de: function (p) { return '\ud83c\udf89\ud83c\udf89\ud83c\udf89 ES IST SOWEIT! \ud83c\udf89\ud83c\udf89\ud83c\udf89\n\n' + p.name + ' ist gelandet und wir k\u00f6nnen nicht aufh\u00f6ren dar\u00fcber zu reden!\n\n' + p.desc + '\n\n' + (p.features.length ? '\ud83d\ude0d Das ist alles drin:\n' + p.features.map(function (f) { return '\ud83d\udca5 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? 'Und das Beste? Nur ' + p.price + '! \ud83e\udd11\n\n' : '') + 'TAGGT jemanden der das braucht! \ud83d\udc47\ud83d\udc47\ud83d\udc47'; },
|
||||||
|
en: function (p) { return '\ud83c\udf89\ud83c\udf89\ud83c\udf89 IT\'S HERE! \ud83c\udf89\ud83c\udf89\ud83c\udf89\n\n' + p.name + ' has landed and we can\'t stop talking about it!\n\n' + p.descEn + '\n\n' + (p.features.length ? '\ud83d\ude0d Here\'s what you get:\n' + p.featuresEn.map(function (f) { return '\ud83d\udca5 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? 'Best part? Only ' + p.price + '! \ud83e\udd11\n\n' : '') + 'TAG someone who needs this! \ud83d\udc47\ud83d\udc47\ud83d\udc47'; }
|
||||||
|
},
|
||||||
|
linkedin: {
|
||||||
|
de: function (p) { return '\ud83d\ude80 Plot Twist: ' + p.name + ' existiert jetzt!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Die Highlights (ja, es wird noch besser):\n' + p.features.map(function (f) { return '\ud83d\udcaa ' + f; }).join('\n') + '\n\n' : '') + 'Wer will mitmachen? Schreibt mir! \ud83d\ude0e\n\n' + p.hashtags.slice(0, 3).join(' '); },
|
||||||
|
en: function (p) { return '\ud83d\ude80 Plot Twist: ' + p.name + ' now exists!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'The highlights (yes, it gets even better):\n' + p.featuresEn.map(function (f) { return '\ud83d\udcaa ' + f; }).join('\n') + '\n\n' : '') + 'Who\'s in? DM me! \ud83d\ude0e\n\n' + p.hashtagsEn.slice(0, 3).join(' '); }
|
||||||
|
},
|
||||||
|
tiktok: {
|
||||||
|
de: function (p) { return '\ud83e\udee3 Wenn du ' + p.name + ' noch nicht kennst, lebst du unter einem Stein!\n\n' + p.desc + '\n\n' + (p.price ? '\ud83d\udcb0 ' + p.price + ' \u2013 SCHNAPPER!\n' : '') + '\nSpeichern & Teilen nicht vergessen! \ud83d\ude4f\n\n' + p.hashtags.join(' ') + ' #fyp #gamechanger'; },
|
||||||
|
en: function (p) { return '\ud83e\udee3 If you don\'t know ' + p.name + ' yet, you\'re living under a rock!\n\n' + p.descEn + '\n\n' + (p.price ? '\ud83d\udcb0 ' + p.price + ' \u2013 STEAL!\n' : '') + '\nSave & Share! \ud83d\ude4f\n\n' + p.hashtagsEn.join(' ') + ' #fyp #gamechanger'; }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
minimal: {
|
||||||
|
twitter: {
|
||||||
|
de: function (p) { return p.name + '.\n' + p.desc + (p.url ? '\n\n' + p.url : '') + '\n\n' + p.hashtags.slice(0, 3).join(' '); },
|
||||||
|
en: function (p) { return p.name + '.\n' + p.descEn + (p.url ? '\n\n' + p.url : '') + '\n\n' + p.hashtagsEn.slice(0, 3).join(' '); }
|
||||||
|
},
|
||||||
|
instagram: {
|
||||||
|
de: function (p) { return p.name + '\n\n' + p.desc + (p.features.length ? '\n\n' + p.features.join(' / ') : '') + '\n\n' + p.hashtags.join(' '); },
|
||||||
|
en: function (p) { return p.name + '\n\n' + p.descEn + (p.features.length ? '\n\n' + p.featuresEn.join(' / ') : '') + '\n\n' + p.hashtagsEn.join(' '); }
|
||||||
|
},
|
||||||
|
facebook: {
|
||||||
|
de: function (p) { return p.name + '\n\n' + p.desc + (p.features.length ? '\n\n' + p.features.join(' \u2022 ') : '') + (p.price ? '\n\n' + p.price : '') + (p.url ? '\n\n' + p.url : ''); },
|
||||||
|
en: function (p) { return p.name + '\n\n' + p.descEn + (p.features.length ? '\n\n' + p.featuresEn.join(' \u2022 ') : '') + (p.price ? '\n\n' + p.price : '') + (p.url ? '\n\n' + p.url : ''); }
|
||||||
|
},
|
||||||
|
linkedin: {
|
||||||
|
de: function (p) { return p.name + '\n\n' + p.desc + (p.features.length ? '\n\n' + p.features.map(function (f) { return '\u2192 ' + f; }).join('\n') : '') + (p.url ? '\n\n' + p.url : ''); },
|
||||||
|
en: function (p) { return p.name + '\n\n' + p.descEn + (p.features.length ? '\n\n' + p.featuresEn.map(function (f) { return '\u2192 ' + f; }).join('\n') : '') + (p.url ? '\n\n' + p.url : ''); }
|
||||||
|
},
|
||||||
|
tiktok: {
|
||||||
|
de: function (p) { return p.name + '\n' + p.desc + '\n\n' + p.hashtags.slice(0, 5).join(' ') + ' #minimal'; },
|
||||||
|
en: function (p) { return p.name + '\n' + p.descEn + '\n\n' + p.hashtagsEn.slice(0, 5).join(' ') + ' #minimal'; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Email Templates ---
|
||||||
|
var emailTemplates = {
|
||||||
|
de: function (p) {
|
||||||
|
return 'Betreff: Entdecken Sie ' + p.name + ' \u2013 ' + p.desc.substring(0, 60) + '...\n\n' +
|
||||||
|
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
|
||||||
|
'Sehr geehrte Damen und Herren,\n\n' +
|
||||||
|
'wir freuen uns, Ihnen ' + p.name + ' vorzustellen \u2013 ' + p.desc + '\n\n' +
|
||||||
|
(p.features.length ?
|
||||||
|
'Die wichtigsten Vorteile auf einen Blick:\n\n' +
|
||||||
|
p.features.map(function (f) { return ' \u2714 ' + f; }).join('\n') + '\n\n' : '') +
|
||||||
|
(p.audience ? 'Ideal f\u00fcr: ' + p.audience + '\n\n' : '') +
|
||||||
|
(p.price ? 'Unser Angebot: ' + p.price + '\n\n' : '') +
|
||||||
|
(p.url ? '\u27a1 Jetzt mehr erfahren: ' + p.url + '\n\n' : '') +
|
||||||
|
'Haben Sie Fragen? Antworten Sie einfach auf diese E-Mail \u2013 wir helfen Ihnen gerne weiter.\n\n' +
|
||||||
|
'Mit freundlichen Gr\u00fc\u00dfen,\n' +
|
||||||
|
'Ihr ' + p.name + ' Team\n\n' +
|
||||||
|
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n' +
|
||||||
|
'Sie erhalten diese E-Mail, weil Sie sich f\u00fcr ' + p.name + ' interessieren.\n' +
|
||||||
|
'Abmelden | Datenschutz | Impressum';
|
||||||
|
},
|
||||||
|
en: function (p) {
|
||||||
|
return 'Subject: Discover ' + p.name + ' \u2013 ' + p.descEn.substring(0, 60) + '...\n\n' +
|
||||||
|
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
|
||||||
|
'Dear Customer,\n\n' +
|
||||||
|
'We are excited to introduce ' + p.name + ' \u2013 ' + p.descEn + '\n\n' +
|
||||||
|
(p.features.length ?
|
||||||
|
'Key benefits at a glance:\n\n' +
|
||||||
|
p.featuresEn.map(function (f) { return ' \u2714 ' + f; }).join('\n') + '\n\n' : '') +
|
||||||
|
(p.audience ? 'Ideal for: ' + p.audienceEn + '\n\n' : '') +
|
||||||
|
(p.price ? 'Our offer: ' + p.price + '\n\n' : '') +
|
||||||
|
(p.url ? '\u27a1 Learn more: ' + p.url + '\n\n' : '') +
|
||||||
|
'Have questions? Simply reply to this email \u2013 we\'re happy to help.\n\n' +
|
||||||
|
'Best regards,\n' +
|
||||||
|
'The ' + p.name + ' Team\n\n' +
|
||||||
|
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n' +
|
||||||
|
'You received this email because you showed interest in ' + p.name + '.\n' +
|
||||||
|
'Unsubscribe | Privacy Policy | Legal';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- SEO Templates ---
|
||||||
|
var seoTemplates = {
|
||||||
|
de: function (p) {
|
||||||
|
return 'META TITLE:\n' + p.name + ' \u2013 ' + p.desc.substring(0, 50) + ' | Jetzt entdecken\n\n' +
|
||||||
|
'META DESCRIPTION:\n' + p.desc + (p.features.length ? ' \u2714 ' + p.features.slice(0, 3).join(' \u2714 ') : '') + (p.price ? ' Ab ' + p.price + '.' : '') + ' Jetzt informieren!\n\n' +
|
||||||
|
'SEO KEYWORDS:\n' + p.name + ', ' + p.name.toLowerCase() + ' kaufen, ' + p.name.toLowerCase() + ' test, ' + p.name.toLowerCase() + ' erfahrungen, ' +
|
||||||
|
(p.category ? p.categoryDe + ', ' : '') + 'beste ' + p.name.toLowerCase() + ', ' + p.name.toLowerCase() + ' vergleich, ' + p.name.toLowerCase() + ' angebot\n\n' +
|
||||||
|
'H1 \u00dcBERSCHRIFT:\n' + p.name + ' \u2013 ' + p.desc + '\n\n' +
|
||||||
|
'H2 \u00dcBERSCHRIFTEN:\n' +
|
||||||
|
'Warum ' + p.name + '?\n' +
|
||||||
|
'Funktionen & Vorteile\n' +
|
||||||
|
'F\u00fcr wen ist ' + p.name + ' geeignet?\n' +
|
||||||
|
'Jetzt ' + p.name + ' bestellen\n\n' +
|
||||||
|
'ALT-TEXT F\u00dcR BILDER:\n' +
|
||||||
|
p.name + ' Produktbild \u2013 ' + p.desc.substring(0, 60);
|
||||||
|
},
|
||||||
|
en: function (p) {
|
||||||
|
return 'META TITLE:\n' + p.name + ' \u2013 ' + p.descEn.substring(0, 50) + ' | Discover Now\n\n' +
|
||||||
|
'META DESCRIPTION:\n' + p.descEn + (p.features.length ? ' \u2714 ' + p.featuresEn.slice(0, 3).join(' \u2714 ') : '') + (p.price ? ' From ' + p.price + '.' : '') + ' Learn more now!\n\n' +
|
||||||
|
'SEO KEYWORDS:\n' + p.name + ', buy ' + p.name.toLowerCase() + ', ' + p.name.toLowerCase() + ' review, ' + p.name.toLowerCase() + ' features, ' +
|
||||||
|
(p.category ? p.categoryEn + ', ' : '') + 'best ' + p.name.toLowerCase() + ', ' + p.name.toLowerCase() + ' comparison, ' + p.name.toLowerCase() + ' deal\n\n' +
|
||||||
|
'H1 HEADING:\n' + p.name + ' \u2013 ' + p.descEn + '\n\n' +
|
||||||
|
'H2 HEADINGS:\n' +
|
||||||
|
'Why ' + p.name + '?\n' +
|
||||||
|
'Features & Benefits\n' +
|
||||||
|
'Who is ' + p.name + ' for?\n' +
|
||||||
|
'Order ' + p.name + ' Now\n\n' +
|
||||||
|
'IMAGE ALT TEXT:\n' +
|
||||||
|
p.name + ' product image \u2013 ' + p.descEn.substring(0, 60);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Press Release Templates ---
|
||||||
|
var pressTemplates = {
|
||||||
|
de: function (p) {
|
||||||
|
var today = new Date().toLocaleDateString('de-DE', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
return 'PRESSEMITTEILUNG\n' +
|
||||||
|
'Datum: ' + today + '\n' +
|
||||||
|
'Zur sofortigen Ver\u00f6ffentlichung\n\n' +
|
||||||
|
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
|
||||||
|
p.name + ': ' + p.desc + '\n\n' +
|
||||||
|
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
|
||||||
|
'Wir freuen uns, die Verf\u00fcgbarkeit von ' + p.name + ' bekannt zu geben. ' + p.desc + '\n\n' +
|
||||||
|
(p.features.length ?
|
||||||
|
'Hauptmerkmale von ' + p.name + ':\n\n' +
|
||||||
|
p.features.map(function (f) { return ' \u2022 ' + f; }).join('\n') + '\n\n' : '') +
|
||||||
|
(p.audience ? '"' + p.name + ' wurde speziell f\u00fcr ' + p.audience + ' entwickelt", erkl\u00e4rt das Entwicklerteam.\n\n' : '') +
|
||||||
|
(p.price ? 'Verf\u00fcgbarkeit & Preis:\n' + p.name + ' ist ab sofort zum Preis von ' + p.price + ' erh\u00e4ltlich.\n\n' : '') +
|
||||||
|
(p.url ? 'Weitere Informationen finden Sie unter: ' + p.url + '\n\n' : '') +
|
||||||
|
'Pressekontakt:\n' +
|
||||||
|
'E-Mail: presse@' + p.name.toLowerCase().replace(/\s+/g, '') + '.de\n' +
|
||||||
|
'Web: ' + (p.url || 'www.' + p.name.toLowerCase().replace(/\s+/g, '') + '.de') + '\n\n' +
|
||||||
|
'###';
|
||||||
|
},
|
||||||
|
en: function (p) {
|
||||||
|
var today = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
return 'PRESS RELEASE\n' +
|
||||||
|
'Date: ' + today + '\n' +
|
||||||
|
'For Immediate Release\n\n' +
|
||||||
|
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
|
||||||
|
p.name + ': ' + p.descEn + '\n\n' +
|
||||||
|
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
|
||||||
|
'We are pleased to announce the availability of ' + p.name + '. ' + p.descEn + '\n\n' +
|
||||||
|
(p.features.length ?
|
||||||
|
'Key Features of ' + p.name + ':\n\n' +
|
||||||
|
p.featuresEn.map(function (f) { return ' \u2022 ' + f; }).join('\n') + '\n\n' : '') +
|
||||||
|
(p.audience ? '"' + p.name + ' was specifically designed for ' + p.audienceEn + '," says the development team.\n\n' : '') +
|
||||||
|
(p.price ? 'Availability & Pricing:\n' + p.name + ' is available now at ' + p.price + '.\n\n' : '') +
|
||||||
|
(p.url ? 'For more information, visit: ' + p.url + '\n\n' : '') +
|
||||||
|
'Press Contact:\n' +
|
||||||
|
'Email: press@' + p.name.toLowerCase().replace(/\s+/g, '') + '.com\n' +
|
||||||
|
'Web: ' + (p.url || 'www.' + p.name.toLowerCase().replace(/\s+/g, '') + '.com') + '\n\n' +
|
||||||
|
'###';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Category Mapping ---
|
||||||
|
var categoryNames = {
|
||||||
|
tech: { de: 'Technologie', en: 'Technology' },
|
||||||
|
fashion: { de: 'Mode & Bekleidung', en: 'Fashion & Apparel' },
|
||||||
|
food: { de: 'Lebensmittel & Getr\u00e4nke', en: 'Food & Beverages' },
|
||||||
|
health: { de: 'Gesundheit & Wellness', en: 'Health & Wellness' },
|
||||||
|
home: { de: 'Haus & Garten', en: 'Home & Garden' },
|
||||||
|
sport: { de: 'Sport & Fitness', en: 'Sports & Fitness' },
|
||||||
|
beauty: { de: 'Sch\u00f6nheit & Pflege', en: 'Beauty & Care' },
|
||||||
|
education: { de: 'Bildung & Kurse', en: 'Education & Courses' },
|
||||||
|
software: { de: 'Software & Apps', en: 'Software & Apps' },
|
||||||
|
other: { de: 'Sonstiges', en: 'Other' }
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Gather Product Data ---
|
||||||
|
function getProductData() {
|
||||||
|
var name = document.getElementById('productName').value.trim();
|
||||||
|
var desc = document.getElementById('productDescription').value.trim();
|
||||||
|
var price = document.getElementById('productPrice').value.trim();
|
||||||
|
var url = document.getElementById('productUrl').value.trim();
|
||||||
|
var features = document.getElementById('productFeatures').value.trim();
|
||||||
|
var audience = document.getElementById('targetAudience').value.trim();
|
||||||
|
var category = document.getElementById('productCategory').value;
|
||||||
|
|
||||||
|
if (!name || !desc) {
|
||||||
|
showToast(currentLang === 'de' ? 'Bitte Produktname und Beschreibung eingeben!' : 'Please enter product name and description!');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var featureList = features ? features.split(',').map(function (f) { return f.trim(); }).filter(Boolean) : [];
|
||||||
|
var catInfo = categoryNames[category] || { de: '', en: '' };
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: name,
|
||||||
|
desc: desc,
|
||||||
|
descEn: desc, // User provides in their language; used as-is
|
||||||
|
price: price,
|
||||||
|
url: url,
|
||||||
|
features: featureList,
|
||||||
|
featuresEn: featureList,
|
||||||
|
audience: audience,
|
||||||
|
audienceEn: audience,
|
||||||
|
category: category,
|
||||||
|
categoryDe: catInfo.de,
|
||||||
|
categoryEn: catInfo.en,
|
||||||
|
hashtags: generateHashtags(name, featureList, category, 'de'),
|
||||||
|
hashtagsEn: generateHashtags(name, featureList, category, 'en')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Generate Hashtags ---
|
||||||
|
function generateHashtags(name, features, category, lang) {
|
||||||
|
var tags = [];
|
||||||
|
tags.push('#' + name.replace(/\s+/g, ''));
|
||||||
|
|
||||||
|
var catTags = {
|
||||||
|
tech: { de: ['#Technologie', '#Innovation', '#TechNews', '#Digital', '#Gadget'], en: ['#Technology', '#Innovation', '#TechNews', '#Digital', '#Gadget'] },
|
||||||
|
fashion: { de: ['#Mode', '#Fashion', '#Style', '#OOTD', '#Trend'], en: ['#Fashion', '#Style', '#OOTD', '#Trend', '#Outfit'] },
|
||||||
|
food: { de: ['#Foodie', '#Lecker', '#Essen', '#Kochen', '#Genuss'], en: ['#Foodie', '#Delicious', '#FoodLover', '#Cooking', '#Yummy'] },
|
||||||
|
health: { de: ['#Gesundheit', '#Wellness', '#Fitness', '#Wohlbefinden'], en: ['#Health', '#Wellness', '#Fitness', '#Wellbeing'] },
|
||||||
|
home: { de: ['#Zuhause', '#Wohnen', '#Interior', '#HomeDecor'], en: ['#Home', '#Living', '#Interior', '#HomeDecor'] },
|
||||||
|
sport: { de: ['#Sport', '#Fitness', '#Training', '#Motivation'], en: ['#Sports', '#Fitness', '#Training', '#Motivation'] },
|
||||||
|
beauty: { de: ['#Beauty', '#Pflege', '#Skincare', '#Sch\u00f6nheit'], en: ['#Beauty', '#Skincare', '#SelfCare', '#Glow'] },
|
||||||
|
education: { de: ['#Bildung', '#Lernen', '#Wissen', '#Weiterbildung'], en: ['#Education', '#Learning', '#Knowledge', '#Growth'] },
|
||||||
|
software: { de: ['#Software', '#App', '#Digital', '#SaaS', '#Produktivit\u00e4t'], en: ['#Software', '#App', '#Digital', '#SaaS', '#Productivity'] },
|
||||||
|
other: { de: ['#Neu', '#MustHave', '#Empfehlung'], en: ['#New', '#MustHave', '#Recommended'] }
|
||||||
|
};
|
||||||
|
|
||||||
|
var ct = catTags[category];
|
||||||
|
if (ct) {
|
||||||
|
tags = tags.concat(ct[lang] || ct.en);
|
||||||
|
}
|
||||||
|
|
||||||
|
features.slice(0, 2).forEach(function (f) {
|
||||||
|
tags.push('#' + f.replace(/\s+/g, '').replace(/[^a-zA-Z0-9\u00c0-\u017e]/g, ''));
|
||||||
|
});
|
||||||
|
|
||||||
|
return tags.filter(function (t, i, arr) { return arr.indexOf(t) === i; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Generate Slogans ---
|
||||||
|
function generateSlogans(p) {
|
||||||
|
var slogans = [];
|
||||||
|
|
||||||
|
var sloganTemplatesDe = [
|
||||||
|
p.name + ' \u2013 Weil du das Beste verdienst.',
|
||||||
|
p.name + '. Einfach. Besser. Anders.',
|
||||||
|
'Die Zukunft hei\u00dft ' + p.name + '.',
|
||||||
|
p.name + ' \u2013 Dein n\u00e4chster Schritt nach vorn.',
|
||||||
|
'Erlebe den Unterschied mit ' + p.name + '.',
|
||||||
|
p.name + '. Mehr als du erwartest.'
|
||||||
|
];
|
||||||
|
|
||||||
|
var sloganTemplatesEn = [
|
||||||
|
p.name + ' \u2013 Because you deserve the best.',
|
||||||
|
p.name + '. Simple. Better. Different.',
|
||||||
|
'The future is called ' + p.name + '.',
|
||||||
|
p.name + ' \u2013 Your next step forward.',
|
||||||
|
'Experience the difference with ' + p.name + '.',
|
||||||
|
p.name + '. More than you expect.'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (var i = 0; i < sloganTemplatesDe.length; i++) {
|
||||||
|
slogans.push({ de: sloganTemplatesDe[i], en: sloganTemplatesEn[i] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return slogans;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Generate Landing Page HTML ---
|
||||||
|
function generateLandingPage(p) {
|
||||||
|
var accentColor = '#6C5CE7';
|
||||||
|
return '<!DOCTYPE html>\n' +
|
||||||
|
'<html lang="de">\n<head>\n' +
|
||||||
|
' <meta charset="UTF-8">\n' +
|
||||||
|
' <meta name="viewport" content="width=device-width, initial-scale=1.0">\n' +
|
||||||
|
' <title>' + p.name + ' \u2013 ' + p.desc.substring(0, 60) + '</title>\n' +
|
||||||
|
' <meta name="description" content="' + p.desc + '">\n' +
|
||||||
|
' <meta property="og:title" content="' + p.name + '">\n' +
|
||||||
|
' <meta property="og:description" content="' + p.desc + '">\n' +
|
||||||
|
' <style>\n' +
|
||||||
|
' * { margin: 0; padding: 0; box-sizing: border-box; }\n' +
|
||||||
|
' body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #333; }\n' +
|
||||||
|
' .hero { background: linear-gradient(135deg, ' + accentColor + ', #00CEC9); color: #fff; padding: 80px 20px; text-align: center; }\n' +
|
||||||
|
' .hero h1 { font-size: 3rem; margin-bottom: 16px; }\n' +
|
||||||
|
' .hero p { font-size: 1.3rem; opacity: 0.9; max-width: 600px; margin: 0 auto 32px; }\n' +
|
||||||
|
' .cta-btn { display: inline-block; padding: 16px 40px; background: #fff; color: ' + accentColor + '; font-size: 18px; font-weight: 700; border-radius: 50px; text-decoration: none; transition: transform 0.3s; }\n' +
|
||||||
|
' .cta-btn:hover { transform: scale(1.05); }\n' +
|
||||||
|
' .features { padding: 60px 20px; max-width: 800px; margin: 0 auto; }\n' +
|
||||||
|
' .features h2 { text-align: center; font-size: 2rem; margin-bottom: 40px; }\n' +
|
||||||
|
' .feature-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 30px; }\n' +
|
||||||
|
' .feature { text-align: center; padding: 24px; }\n' +
|
||||||
|
' .feature h3 { color: ' + accentColor + '; margin-bottom: 8px; }\n' +
|
||||||
|
(p.price ? ' .pricing { text-align: center; padding: 60px 20px; background: #f8f9fa; }\n' +
|
||||||
|
' .pricing h2 { font-size: 2rem; margin-bottom: 16px; }\n' +
|
||||||
|
' .price-tag { font-size: 3rem; font-weight: 900; color: ' + accentColor + '; }\n' : '') +
|
||||||
|
' .footer { text-align: center; padding: 30px; color: #888; font-size: 14px; }\n' +
|
||||||
|
' </style>\n' +
|
||||||
|
'</head>\n<body>\n' +
|
||||||
|
' <section class="hero">\n' +
|
||||||
|
' <h1>' + p.name + '</h1>\n' +
|
||||||
|
' <p>' + p.desc + '</p>\n' +
|
||||||
|
(p.url ? ' <a href="' + escapeHtml(p.url) + '" class="cta-btn">Jetzt entdecken / Discover Now</a>\n' : ' <a href="#features" class="cta-btn">Mehr erfahren / Learn More</a>\n') +
|
||||||
|
' </section>\n' +
|
||||||
|
(p.features.length ? ' <section class="features" id="features">\n' +
|
||||||
|
' <h2>Features</h2>\n' +
|
||||||
|
' <div class="feature-grid">\n' +
|
||||||
|
p.features.map(function (f) { return ' <div class="feature">\n <h3>' + escapeHtml(f) + '</h3>\n </div>'; }).join('\n') + '\n' +
|
||||||
|
' </div>\n' +
|
||||||
|
' </section>\n' : '') +
|
||||||
|
(p.price ? ' <section class="pricing">\n' +
|
||||||
|
' <h2>Preis / Price</h2>\n' +
|
||||||
|
' <div class="price-tag">' + escapeHtml(p.price) + '</div>\n' +
|
||||||
|
' </section>\n' : '') +
|
||||||
|
' <footer class="footer">\n' +
|
||||||
|
' © ' + new Date().getFullYear() + ' ' + escapeHtml(p.name) + '. All rights reserved.\n' +
|
||||||
|
' </footer>\n' +
|
||||||
|
'</body>\n</html>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.appendChild(document.createTextNode(str));
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main Generate Function ---
|
||||||
|
function generateAll() {
|
||||||
|
var p = getProductData();
|
||||||
|
if (!p) return;
|
||||||
|
|
||||||
|
var btn = document.getElementById('generateBtn');
|
||||||
|
btn.classList.add('loading');
|
||||||
|
|
||||||
|
setTimeout(function () {
|
||||||
|
var style = templates[selectedStyle] || templates.professional;
|
||||||
|
var platforms = ['twitter', 'instagram', 'facebook', 'linkedin', 'tiktok'];
|
||||||
|
|
||||||
|
platforms.forEach(function (platform) {
|
||||||
|
var tpl = style[platform];
|
||||||
|
if (tpl) {
|
||||||
|
setText(platform + '-de', tpl.de(p));
|
||||||
|
setText(platform + '-en', tpl.en(p));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Email
|
||||||
|
setText('email-de', emailTemplates.de(p));
|
||||||
|
setText('email-en', emailTemplates.en(p));
|
||||||
|
|
||||||
|
// SEO
|
||||||
|
setText('seo-de', seoTemplates.de(p));
|
||||||
|
setText('seo-en', seoTemplates.en(p));
|
||||||
|
|
||||||
|
// Press Release
|
||||||
|
setText('press-de', pressTemplates.de(p));
|
||||||
|
setText('press-en', pressTemplates.en(p));
|
||||||
|
|
||||||
|
// Slogans
|
||||||
|
var slogans = generateSlogans(p);
|
||||||
|
var sloganGrid = document.getElementById('sloganGrid');
|
||||||
|
sloganGrid.innerHTML = '';
|
||||||
|
slogans.forEach(function (s) {
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.className = 'slogan-item';
|
||||||
|
div.innerHTML = '<span class="slogan-lang">DE</span>' + escapeHtml(s.de);
|
||||||
|
div.onclick = function () { copyToClipboard(s.de); };
|
||||||
|
sloganGrid.appendChild(div);
|
||||||
|
|
||||||
|
var divEn = document.createElement('div');
|
||||||
|
divEn.className = 'slogan-item';
|
||||||
|
divEn.innerHTML = '<span class="slogan-lang">EN</span>' + escapeHtml(s.en);
|
||||||
|
divEn.onclick = function () { copyToClipboard(s.en); };
|
||||||
|
sloganGrid.appendChild(divEn);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hashtags
|
||||||
|
var hashtagCloud = document.getElementById('hashtagCloud');
|
||||||
|
hashtagCloud.innerHTML = '';
|
||||||
|
var allTags = p.hashtags.concat(p.hashtagsEn).filter(function (t, i, arr) { return arr.indexOf(t) === i; });
|
||||||
|
allTags.forEach(function (tag) {
|
||||||
|
var span = document.createElement('span');
|
||||||
|
span.className = 'hashtag';
|
||||||
|
span.textContent = tag;
|
||||||
|
span.onclick = function () { copyToClipboard(tag); };
|
||||||
|
hashtagCloud.appendChild(span);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Landing Page
|
||||||
|
var landingHtml = generateLandingPage(p);
|
||||||
|
document.getElementById('landing-code').textContent = landingHtml;
|
||||||
|
var iframe = document.createElement('iframe');
|
||||||
|
iframe.srcdoc = landingHtml;
|
||||||
|
var previewDiv = document.getElementById('landing-preview');
|
||||||
|
previewDiv.innerHTML = '';
|
||||||
|
previewDiv.appendChild(iframe);
|
||||||
|
|
||||||
|
// Store data for export
|
||||||
|
generatedData = {
|
||||||
|
product: p,
|
||||||
|
style: selectedStyle,
|
||||||
|
social: {},
|
||||||
|
email: { de: emailTemplates.de(p), en: emailTemplates.en(p) },
|
||||||
|
seo: { de: seoTemplates.de(p), en: seoTemplates.en(p) },
|
||||||
|
press: { de: pressTemplates.de(p), en: pressTemplates.en(p) },
|
||||||
|
slogans: slogans,
|
||||||
|
hashtags: allTags,
|
||||||
|
landingPage: landingHtml
|
||||||
|
};
|
||||||
|
|
||||||
|
platforms.forEach(function (platform) {
|
||||||
|
var tpl = style[platform];
|
||||||
|
if (tpl) {
|
||||||
|
generatedData.social[platform] = { de: tpl.de(p), en: tpl.en(p) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show results
|
||||||
|
document.getElementById('results').classList.remove('hidden');
|
||||||
|
document.getElementById('results').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
|
||||||
|
btn.classList.remove('loading');
|
||||||
|
showToast(currentLang === 'de' ? 'Alle Werbematerialien wurden generiert!' : 'All promotion materials generated!');
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.generateAll = generateAll;
|
||||||
|
|
||||||
|
// --- Helper Functions ---
|
||||||
|
function setText(id, text) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToClipboard(text) {
|
||||||
|
navigator.clipboard.writeText(text).then(function () {
|
||||||
|
showToast(currentLang === 'de' ? 'Kopiert!' : 'Copied!');
|
||||||
|
}).catch(function () {
|
||||||
|
fallbackCopy(text);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackCopy(text) {
|
||||||
|
var textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.opacity = '0';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
showToast(currentLang === 'de' ? 'Kopiert!' : 'Copied!');
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyText(id) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
if (el) copyToClipboard(el.textContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.copyText = copyText;
|
||||||
|
|
||||||
|
function copyAll(section) {
|
||||||
|
if (!generatedData) return;
|
||||||
|
var text = '';
|
||||||
|
if (section === 'social') {
|
||||||
|
Object.keys(generatedData.social).forEach(function (platform) {
|
||||||
|
text += '=== ' + platform.toUpperCase() + ' (DE) ===\n' + generatedData.social[platform].de + '\n\n';
|
||||||
|
text += '=== ' + platform.toUpperCase() + ' (EN) ===\n' + generatedData.social[platform].en + '\n\n';
|
||||||
|
});
|
||||||
|
} else if (section === 'email') {
|
||||||
|
text = '=== EMAIL (DE) ===\n' + generatedData.email.de + '\n\n=== EMAIL (EN) ===\n' + generatedData.email.en;
|
||||||
|
} else if (section === 'seo') {
|
||||||
|
text = '=== SEO (DE) ===\n' + generatedData.seo.de + '\n\n=== SEO (EN) ===\n' + generatedData.seo.en;
|
||||||
|
} else if (section === 'press') {
|
||||||
|
text = '=== PRESS (DE) ===\n' + generatedData.press.de + '\n\n=== PRESS (EN) ===\n' + generatedData.press.en;
|
||||||
|
}
|
||||||
|
copyToClipboard(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.copyAll = copyAll;
|
||||||
|
|
||||||
|
function copyHashtags() {
|
||||||
|
if (generatedData) copyToClipboard(generatedData.hashtags.join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.copyHashtags = copyHashtags;
|
||||||
|
|
||||||
|
// --- Tabs ---
|
||||||
|
function switchTab(btn, tab) {
|
||||||
|
var parent = btn.closest('.result-card');
|
||||||
|
parent.querySelectorAll('.tab-btn').forEach(function (b) { b.classList.remove('active'); });
|
||||||
|
btn.classList.add('active');
|
||||||
|
parent.querySelectorAll('.tab-content').forEach(function (c) { c.classList.add('hidden'); });
|
||||||
|
document.getElementById('tab-' + tab).classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.switchTab = switchTab;
|
||||||
|
|
||||||
|
// --- Landing Page Preview Toggle ---
|
||||||
|
function togglePreview(mode) {
|
||||||
|
var preview = document.getElementById('landing-preview');
|
||||||
|
var code = document.getElementById('landing-code');
|
||||||
|
var buttons = document.querySelectorAll('.preview-btn');
|
||||||
|
|
||||||
|
buttons.forEach(function (b) { b.classList.remove('active'); });
|
||||||
|
|
||||||
|
if (mode === 'preview') {
|
||||||
|
preview.classList.remove('hidden');
|
||||||
|
code.classList.add('hidden');
|
||||||
|
buttons[0].classList.add('active');
|
||||||
|
} else {
|
||||||
|
preview.classList.add('hidden');
|
||||||
|
code.classList.remove('hidden');
|
||||||
|
buttons[1].classList.add('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.togglePreview = togglePreview;
|
||||||
|
|
||||||
|
// --- Export Functions ---
|
||||||
|
function exportAs(format) {
|
||||||
|
if (!generatedData) {
|
||||||
|
showToast(currentLang === 'de' ? 'Bitte zuerst generieren!' : 'Please generate first!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = '';
|
||||||
|
var filename = 'promo-' + generatedData.product.name.replace(/\s+/g, '-').toLowerCase();
|
||||||
|
var mimeType = 'text/plain';
|
||||||
|
|
||||||
|
if (format === 'txt') {
|
||||||
|
content = buildTextExport();
|
||||||
|
filename += '.txt';
|
||||||
|
} else if (format === 'html') {
|
||||||
|
content = generatedData.landingPage;
|
||||||
|
filename += '-landingpage.html';
|
||||||
|
mimeType = 'text/html';
|
||||||
|
} else if (format === 'json') {
|
||||||
|
content = JSON.stringify(generatedData, null, 2);
|
||||||
|
filename += '.json';
|
||||||
|
mimeType = 'application/json';
|
||||||
|
} else if (format === 'csv') {
|
||||||
|
content = buildCsvExport();
|
||||||
|
filename += '.csv';
|
||||||
|
mimeType = 'text/csv';
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadFile(content, filename, mimeType);
|
||||||
|
showToast((currentLang === 'de' ? 'Export als ' : 'Exported as ') + format.toUpperCase() + '!');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.exportAs = exportAs;
|
||||||
|
|
||||||
|
function buildTextExport() {
|
||||||
|
var d = generatedData;
|
||||||
|
var lines = [];
|
||||||
|
lines.push('========================================');
|
||||||
|
lines.push('PROMOMASTER - WERBEMATERIALIEN / PROMOTION MATERIALS');
|
||||||
|
lines.push('Produkt / Product: ' + d.product.name);
|
||||||
|
lines.push('Erstellt am / Generated: ' + new Date().toLocaleString());
|
||||||
|
lines.push('========================================\n');
|
||||||
|
|
||||||
|
Object.keys(d.social).forEach(function (platform) {
|
||||||
|
lines.push('\n--- ' + platform.toUpperCase() + ' (DE) ---');
|
||||||
|
lines.push(d.social[platform].de);
|
||||||
|
lines.push('\n--- ' + platform.toUpperCase() + ' (EN) ---');
|
||||||
|
lines.push(d.social[platform].en);
|
||||||
|
});
|
||||||
|
|
||||||
|
lines.push('\n\n--- E-MAIL MARKETING (DE) ---');
|
||||||
|
lines.push(d.email.de);
|
||||||
|
lines.push('\n--- E-MAIL MARKETING (EN) ---');
|
||||||
|
lines.push(d.email.en);
|
||||||
|
|
||||||
|
lines.push('\n\n--- SEO (DE) ---');
|
||||||
|
lines.push(d.seo.de);
|
||||||
|
lines.push('\n--- SEO (EN) ---');
|
||||||
|
lines.push(d.seo.en);
|
||||||
|
|
||||||
|
lines.push('\n\n--- PRESSEMITTEILUNG / PRESS RELEASE (DE) ---');
|
||||||
|
lines.push(d.press.de);
|
||||||
|
lines.push('\n--- PRESS RELEASE (EN) ---');
|
||||||
|
lines.push(d.press.en);
|
||||||
|
|
||||||
|
lines.push('\n\n--- SLOGANS ---');
|
||||||
|
d.slogans.forEach(function (s) {
|
||||||
|
lines.push('DE: ' + s.de);
|
||||||
|
lines.push('EN: ' + s.en);
|
||||||
|
});
|
||||||
|
|
||||||
|
lines.push('\n\n--- HASHTAGS ---');
|
||||||
|
lines.push(d.hashtags.join(' '));
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCsvExport() {
|
||||||
|
var d = generatedData;
|
||||||
|
var rows = [['Platform', 'Language', 'Content']];
|
||||||
|
|
||||||
|
Object.keys(d.social).forEach(function (platform) {
|
||||||
|
rows.push([platform, 'DE', '"' + d.social[platform].de.replace(/"/g, '""') + '"']);
|
||||||
|
rows.push([platform, 'EN', '"' + d.social[platform].en.replace(/"/g, '""') + '"']);
|
||||||
|
});
|
||||||
|
|
||||||
|
rows.push(['email', 'DE', '"' + d.email.de.replace(/"/g, '""') + '"']);
|
||||||
|
rows.push(['email', 'EN', '"' + d.email.en.replace(/"/g, '""') + '"']);
|
||||||
|
rows.push(['seo', 'DE', '"' + d.seo.de.replace(/"/g, '""') + '"']);
|
||||||
|
rows.push(['seo', 'EN', '"' + d.seo.en.replace(/"/g, '""') + '"']);
|
||||||
|
rows.push(['press', 'DE', '"' + d.press.de.replace(/"/g, '""') + '"']);
|
||||||
|
rows.push(['press', 'EN', '"' + d.press.en.replace(/"/g, '""') + '"']);
|
||||||
|
|
||||||
|
d.slogans.forEach(function (s, i) {
|
||||||
|
rows.push(['slogan_' + (i + 1), 'DE', '"' + s.de.replace(/"/g, '""') + '"']);
|
||||||
|
rows.push(['slogan_' + (i + 1), 'EN', '"' + s.en.replace(/"/g, '""') + '"']);
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows.map(function (r) { return r.join(','); }).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadFile(content, filename, mimeType) {
|
||||||
|
var blob = new Blob([content], { type: mimeType + ';charset=utf-8' });
|
||||||
|
var url = URL.createObjectURL(blob);
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Toast ---
|
||||||
|
function showToast(msg) {
|
||||||
|
var toast = document.getElementById('toast');
|
||||||
|
toast.textContent = msg;
|
||||||
|
toast.classList.remove('hidden');
|
||||||
|
toast.classList.add('show');
|
||||||
|
setTimeout(function () {
|
||||||
|
toast.classList.remove('show');
|
||||||
|
setTimeout(function () { toast.classList.add('hidden'); }, 400);
|
||||||
|
}, 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Initialize ---
|
||||||
|
setLanguage('de');
|
||||||
|
})();
|
||||||
@@ -0,0 +1,724 @@
|
|||||||
|
/* === PromoMaster - Product Promotion Tool === */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary: #6C5CE7;
|
||||||
|
--primary-dark: #5A4BD1;
|
||||||
|
--primary-light: #A29BFE;
|
||||||
|
--accent: #00CEC9;
|
||||||
|
--accent-dark: #00B5B0;
|
||||||
|
--bg: #0F0F1A;
|
||||||
|
--bg-card: #1A1A2E;
|
||||||
|
--bg-card-hover: #222240;
|
||||||
|
--text: #EAEAEA;
|
||||||
|
--text-muted: #8B8BA3;
|
||||||
|
--border: #2D2D4A;
|
||||||
|
--success: #00E676;
|
||||||
|
--warning: #FFD93D;
|
||||||
|
--danger: #FF6B6B;
|
||||||
|
--radius: 16px;
|
||||||
|
--radius-sm: 10px;
|
||||||
|
--shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.4);
|
||||||
|
--glow: 0 0 30px rgba(108, 92, 231, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.6;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Language Toggle === */
|
||||||
|
.lang-toggle {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 30px;
|
||||||
|
padding: 4px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 26px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-btn.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-btn:hover:not(.active) {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Hero === */
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
padding: 80px 0 50px;
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-bg {
|
||||||
|
position: absolute;
|
||||||
|
top: -50%;
|
||||||
|
left: -50%;
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
background: radial-gradient(ellipse at 50% 50%, rgba(108, 92, 231, 0.15) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse at 80% 20%, rgba(0, 206, 201, 0.1) 0%, transparent 40%);
|
||||||
|
animation: heroPulse 8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes heroPulse {
|
||||||
|
0%, 100% { transform: scale(1); opacity: 0.8; }
|
||||||
|
50% { transform: scale(1.05); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
font-size: clamp(2.5rem, 6vw, 4rem);
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: -2px;
|
||||||
|
position: relative;
|
||||||
|
background: linear-gradient(135deg, var(--primary-light), var(--accent));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-icon {
|
||||||
|
font-size: 0.8em;
|
||||||
|
-webkit-text-fill-color: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
font-size: clamp(1rem, 2.5vw, 1.3rem);
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 12px;
|
||||||
|
position: relative;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Cards === */
|
||||||
|
.card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 32px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transition: border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Step Header === */
|
||||||
|
.step-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-number {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--accent));
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-header h2 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Form === */
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group.full-width {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group textarea,
|
||||||
|
.form-group select {
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 15px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group textarea:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(108, 92, 231, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group select {
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%238B8BA3' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 16px center;
|
||||||
|
padding-right: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Style Options === */
|
||||||
|
.style-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-option {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 20px 14px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-option:hover {
|
||||||
|
border-color: var(--primary-light);
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-option.selected {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(108, 92, 231, 0.1);
|
||||||
|
box-shadow: var(--glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-option.selected .style-label {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Generate Button === */
|
||||||
|
.generate-section {
|
||||||
|
text-align: center;
|
||||||
|
margin: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-generate {
|
||||||
|
padding: 18px 48px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: inherit;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--accent));
|
||||||
|
border: none;
|
||||||
|
border-radius: 50px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 8px 30px rgba(108, 92, 231, 0.4);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-generate:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 12px 40px rgba(108, 92, 231, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-generate:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-generate.loading {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-generate.loading .btn-icon {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
font-size: 22px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Results === */
|
||||||
|
.results-section {
|
||||||
|
animation: fadeInUp 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from { opacity: 0; transform: translateY(30px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-card {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-header h3 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy-all {
|
||||||
|
padding: 8px 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--primary-light);
|
||||||
|
background: rgba(108, 92, 231, 0.1);
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
border-radius: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy-all:hover {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Tabs === */
|
||||||
|
.result-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Language Results === */
|
||||||
|
.lang-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-result {
|
||||||
|
position: relative;
|
||||||
|
padding: 20px;
|
||||||
|
padding-left: 56px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
left: 16px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #E74C3C, #C0392B);
|
||||||
|
border-radius: 6px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-badge.en {
|
||||||
|
background: linear-gradient(135deg, #2980B9, #2471A3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-text {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.8;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
color: var(--text);
|
||||||
|
padding-right: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy:hover {
|
||||||
|
background: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Slogans === */
|
||||||
|
.slogan-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slogan-item {
|
||||||
|
padding: 18px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slogan-item:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slogan-item .slogan-lang {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Hashtags === */
|
||||||
|
.hashtag-cloud {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hashtag {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: rgba(108, 92, 231, 0.1);
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-light);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hashtag:hover {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Landing Page Preview === */
|
||||||
|
.landing-preview-container {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-toggle {
|
||||||
|
display: flex;
|
||||||
|
background: var(--bg);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-btn.active {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-bottom: 2px solid var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-preview {
|
||||||
|
min-height: 300px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-preview iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 500px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-code {
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow-x: auto;
|
||||||
|
max-height: 400px;
|
||||||
|
white-space: pre;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Export === */
|
||||||
|
.export-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-export {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 24px 16px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-export:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Toast === */
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 30px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(20px);
|
||||||
|
padding: 14px 28px;
|
||||||
|
background: var(--success);
|
||||||
|
color: #000;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 30px;
|
||||||
|
z-index: 1000;
|
||||||
|
opacity: 0;
|
||||||
|
transition: all 0.4s ease;
|
||||||
|
box-shadow: 0 8px 30px rgba(0, 230, 118, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Footer === */
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin-top: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Hidden === */
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Responsive === */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slogan-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 24px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding: 60px 0 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-toggle {
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-tabs {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.style-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-generate {
|
||||||
|
padding: 16px 32px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
//
|
||||||
|
// AppDelegate.swift
|
||||||
|
// PsytranceVisualizer
|
||||||
|
//
|
||||||
|
// Application delegate handling app lifecycle
|
||||||
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
/// Application delegate
|
||||||
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
private var mainWindowController: MainWindowController?
|
||||||
|
|
||||||
|
// MARK: - App Lifecycle
|
||||||
|
|
||||||
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
// Request microphone permission
|
||||||
|
requestMicrophonePermission()
|
||||||
|
|
||||||
|
// Create and show main window
|
||||||
|
mainWindowController = MainWindowController()
|
||||||
|
mainWindowController?.showWindow(nil)
|
||||||
|
mainWindowController?.window?.makeKeyAndOrderFront(nil)
|
||||||
|
|
||||||
|
// Activate the application
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
|
||||||
|
print("[AppDelegate] Application launched")
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
|
// Save settings
|
||||||
|
SettingsManager.shared.saveNow()
|
||||||
|
|
||||||
|
print("[AppDelegate] Application terminating")
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Permissions
|
||||||
|
|
||||||
|
private func requestMicrophonePermission() {
|
||||||
|
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||||
|
case .authorized:
|
||||||
|
print("[AppDelegate] Microphone access already authorized")
|
||||||
|
|
||||||
|
case .notDetermined:
|
||||||
|
AVCaptureDevice.requestAccess(for: .audio) { granted in
|
||||||
|
if granted {
|
||||||
|
print("[AppDelegate] Microphone access granted")
|
||||||
|
} else {
|
||||||
|
print("[AppDelegate] Microphone access denied")
|
||||||
|
self.showMicrophonePermissionAlert()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case .denied, .restricted:
|
||||||
|
print("[AppDelegate] Microphone access denied or restricted")
|
||||||
|
showMicrophonePermissionAlert()
|
||||||
|
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showMicrophonePermissionAlert() {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = "Microphone Access Required"
|
||||||
|
alert.informativeText = "Psytrance Visualizer needs access to your audio input to visualize music. Please enable microphone access in System Preferences > Security & Privacy > Privacy > Microphone."
|
||||||
|
alert.alertStyle = .warning
|
||||||
|
alert.addButton(withTitle: "Open System Preferences")
|
||||||
|
alert.addButton(withTitle: "Cancel")
|
||||||
|
|
||||||
|
if alert.runModal() == .alertFirstButtonReturn {
|
||||||
|
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone") {
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Menu Actions
|
||||||
|
|
||||||
|
@IBAction func showAbout(_ sender: Any) {
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = "Psytrance Visualizer"
|
||||||
|
alert.informativeText = """
|
||||||
|
An audio-reactive visualizer for psytrance music.
|
||||||
|
|
||||||
|
8 Visualization Modes:
|
||||||
|
1 - FFT Classic
|
||||||
|
2 - Mel Spectrogram
|
||||||
|
3 - Sub-Bass
|
||||||
|
4 - Sidechain Pump
|
||||||
|
5 - Harmonic/Noise
|
||||||
|
6 - Mandelbrot
|
||||||
|
7 - Tunnel Warp
|
||||||
|
8 - DMT Geometry
|
||||||
|
|
||||||
|
Keyboard Shortcuts:
|
||||||
|
1-8: Switch visualization mode
|
||||||
|
F: Toggle fullscreen
|
||||||
|
ESC: Exit fullscreen
|
||||||
|
|
||||||
|
Tip: Use a virtual audio device like BlackHole to route system audio.
|
||||||
|
"""
|
||||||
|
alert.alertStyle = .informational
|
||||||
|
alert.runModal()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
//
|
||||||
|
// PsytranceVisualizerApp.swift
|
||||||
|
// PsytranceVisualizer
|
||||||
|
//
|
||||||
|
// Main application entry point
|
||||||
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
// MARK: - Main Entry Point
|
||||||
|
|
||||||
|
/// Application entry point
|
||||||
|
@main
|
||||||
|
struct PsytranceVisualizerApp {
|
||||||
|
static func main() {
|
||||||
|
// Create the application
|
||||||
|
let app = NSApplication.shared
|
||||||
|
|
||||||
|
// Set up the delegate
|
||||||
|
let delegate = AppDelegate()
|
||||||
|
app.delegate = delegate
|
||||||
|
|
||||||
|
// Set activation policy
|
||||||
|
app.setActivationPolicy(.regular)
|
||||||
|
|
||||||
|
// Create the main menu
|
||||||
|
setupMainMenu()
|
||||||
|
|
||||||
|
// Run the application
|
||||||
|
app.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets up the application's main menu
|
||||||
|
private static func setupMainMenu() {
|
||||||
|
let mainMenu = NSMenu()
|
||||||
|
|
||||||
|
// Application menu
|
||||||
|
let appMenuItem = NSMenuItem()
|
||||||
|
mainMenu.addItem(appMenuItem)
|
||||||
|
|
||||||
|
let appMenu = NSMenu()
|
||||||
|
appMenuItem.submenu = appMenu
|
||||||
|
|
||||||
|
appMenu.addItem(withTitle: "About Psytrance Visualizer",
|
||||||
|
action: #selector(AppDelegate.showAbout(_:)),
|
||||||
|
keyEquivalent: "")
|
||||||
|
|
||||||
|
appMenu.addItem(NSMenuItem.separator())
|
||||||
|
|
||||||
|
appMenu.addItem(withTitle: "Hide Psytrance Visualizer",
|
||||||
|
action: #selector(NSApplication.hide(_:)),
|
||||||
|
keyEquivalent: "h")
|
||||||
|
|
||||||
|
let hideOthersItem = appMenu.addItem(withTitle: "Hide Others",
|
||||||
|
action: #selector(NSApplication.hideOtherApplications(_:)),
|
||||||
|
keyEquivalent: "h")
|
||||||
|
hideOthersItem.keyEquivalentModifierMask = [.command, .option]
|
||||||
|
|
||||||
|
appMenu.addItem(withTitle: "Show All",
|
||||||
|
action: #selector(NSApplication.unhideAllApplications(_:)),
|
||||||
|
keyEquivalent: "")
|
||||||
|
|
||||||
|
appMenu.addItem(NSMenuItem.separator())
|
||||||
|
|
||||||
|
appMenu.addItem(withTitle: "Quit Psytrance Visualizer",
|
||||||
|
action: #selector(NSApplication.terminate(_:)),
|
||||||
|
keyEquivalent: "q")
|
||||||
|
|
||||||
|
// View menu
|
||||||
|
let viewMenuItem = NSMenuItem()
|
||||||
|
mainMenu.addItem(viewMenuItem)
|
||||||
|
|
||||||
|
let viewMenu = NSMenu(title: "View")
|
||||||
|
viewMenuItem.submenu = viewMenu
|
||||||
|
|
||||||
|
viewMenu.addItem(withTitle: "Toggle Fullscreen",
|
||||||
|
action: #selector(NSWindow.toggleFullScreen(_:)),
|
||||||
|
keyEquivalent: "f")
|
||||||
|
|
||||||
|
viewMenu.addItem(NSMenuItem.separator())
|
||||||
|
|
||||||
|
// Visualization mode submenu
|
||||||
|
let modesMenuItem = NSMenuItem(title: "Visualization Mode", action: nil, keyEquivalent: "")
|
||||||
|
let modesMenu = NSMenu()
|
||||||
|
|
||||||
|
for mode in VisualizationMode.allCases {
|
||||||
|
let item = NSMenuItem(title: mode.displayName,
|
||||||
|
action: nil,
|
||||||
|
keyEquivalent: mode.shortcut)
|
||||||
|
item.tag = mode.rawValue
|
||||||
|
modesMenu.addItem(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
modesMenuItem.submenu = modesMenu
|
||||||
|
viewMenu.addItem(modesMenuItem)
|
||||||
|
|
||||||
|
// Window menu
|
||||||
|
let windowMenuItem = NSMenuItem()
|
||||||
|
mainMenu.addItem(windowMenuItem)
|
||||||
|
|
||||||
|
let windowMenu = NSMenu(title: "Window")
|
||||||
|
windowMenuItem.submenu = windowMenu
|
||||||
|
|
||||||
|
windowMenu.addItem(withTitle: "Minimize",
|
||||||
|
action: #selector(NSWindow.miniaturize(_:)),
|
||||||
|
keyEquivalent: "m")
|
||||||
|
|
||||||
|
windowMenu.addItem(withTitle: "Zoom",
|
||||||
|
action: #selector(NSWindow.zoom(_:)),
|
||||||
|
keyEquivalent: "")
|
||||||
|
|
||||||
|
windowMenu.addItem(NSMenuItem.separator())
|
||||||
|
|
||||||
|
windowMenu.addItem(withTitle: "Bring All to Front",
|
||||||
|
action: #selector(NSApplication.arrangeInFront(_:)),
|
||||||
|
keyEquivalent: "")
|
||||||
|
|
||||||
|
// Help menu
|
||||||
|
let helpMenuItem = NSMenuItem()
|
||||||
|
mainMenu.addItem(helpMenuItem)
|
||||||
|
|
||||||
|
let helpMenu = NSMenu(title: "Help")
|
||||||
|
helpMenuItem.submenu = helpMenu
|
||||||
|
|
||||||
|
helpMenu.addItem(withTitle: "Psytrance Visualizer Help",
|
||||||
|
action: #selector(AppDelegate.showAbout(_:)),
|
||||||
|
keyEquivalent: "?")
|
||||||
|
|
||||||
|
NSApp.mainMenu = mainMenu
|
||||||
|
NSApp.windowsMenu = windowMenu
|
||||||
|
NSApp.helpMenu = helpMenu
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
//
|
||||||
|
// AudioInputManager.swift
|
||||||
|
// PsytranceVisualizer
|
||||||
|
//
|
||||||
|
// Manages audio input devices and captures audio buffers
|
||||||
|
//
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import CoreAudio
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Represents an audio input device
|
||||||
|
struct AudioDevice: Identifiable, Hashable {
|
||||||
|
let id: AudioDeviceID
|
||||||
|
let uid: String
|
||||||
|
let name: String
|
||||||
|
let manufacturer: String
|
||||||
|
let isInput: Bool
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func == (lhs: AudioDevice, rhs: AudioDevice) -> Bool {
|
||||||
|
lhs.uid == rhs.uid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages audio input capture using AVAudioEngine
|
||||||
|
final class AudioInputManager: ObservableObject {
|
||||||
|
// MARK: - Published Properties
|
||||||
|
|
||||||
|
@Published private(set) var availableDevices: [AudioDevice] = []
|
||||||
|
@Published private(set) var selectedDevice: AudioDevice?
|
||||||
|
@Published private(set) var isRunning = false
|
||||||
|
@Published private(set) var currentBufferSize: Int = 1024
|
||||||
|
|
||||||
|
// MARK: - Audio Properties
|
||||||
|
|
||||||
|
private var audioEngine: AVAudioEngine?
|
||||||
|
private var inputNode: AVAudioInputNode?
|
||||||
|
private let sampleRate: Double = 44100.0
|
||||||
|
|
||||||
|
// MARK: - Callbacks
|
||||||
|
|
||||||
|
var onAudioBuffer: ((AVAudioPCMBuffer) -> Void)?
|
||||||
|
|
||||||
|
// MARK: - Private Properties
|
||||||
|
|
||||||
|
private var deviceListenerBlock: AudioObjectPropertyListenerBlock?
|
||||||
|
private let processingQueue = DispatchQueue(label: "com.psytrance.audio", qos: .userInteractive)
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init() {
|
||||||
|
refreshDeviceList()
|
||||||
|
setupDeviceChangeListener()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
stop()
|
||||||
|
removeDeviceChangeListener()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Methods
|
||||||
|
|
||||||
|
/// Returns list of available audio input devices
|
||||||
|
func getAvailableInputDevices() -> [AudioDevice] {
|
||||||
|
return availableDevices
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes the list of available audio input devices
|
||||||
|
func refreshDeviceList() {
|
||||||
|
var propertyAddress = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDevices,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
|
||||||
|
var dataSize: UInt32 = 0
|
||||||
|
var status = AudioObjectGetPropertyDataSize(
|
||||||
|
AudioObjectID(kAudioObjectSystemObject),
|
||||||
|
&propertyAddress,
|
||||||
|
0,
|
||||||
|
nil,
|
||||||
|
&dataSize
|
||||||
|
)
|
||||||
|
|
||||||
|
guard status == noErr else {
|
||||||
|
print("[AudioInputManager] Failed to get device list size: \(status)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let deviceCount = Int(dataSize) / MemoryLayout<AudioDeviceID>.size
|
||||||
|
var deviceIDs = [AudioDeviceID](repeating: 0, count: deviceCount)
|
||||||
|
|
||||||
|
status = AudioObjectGetPropertyData(
|
||||||
|
AudioObjectID(kAudioObjectSystemObject),
|
||||||
|
&propertyAddress,
|
||||||
|
0,
|
||||||
|
nil,
|
||||||
|
&dataSize,
|
||||||
|
&deviceIDs
|
||||||
|
)
|
||||||
|
|
||||||
|
guard status == noErr else {
|
||||||
|
print("[AudioInputManager] Failed to get device list: \(status)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var devices: [AudioDevice] = []
|
||||||
|
|
||||||
|
for deviceID in deviceIDs {
|
||||||
|
if let device = getDeviceInfo(deviceID: deviceID), device.isInput {
|
||||||
|
devices.append(device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.availableDevices = devices
|
||||||
|
print("[AudioInputManager] Found \(devices.count) input devices")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects an audio input device by UID
|
||||||
|
func selectDevice(uid: String) {
|
||||||
|
guard let device = availableDevices.first(where: { $0.uid == uid }) else {
|
||||||
|
print("[AudioInputManager] Device not found: \(uid)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let wasRunning = isRunning
|
||||||
|
if wasRunning {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedDevice = device
|
||||||
|
setSystemInputDevice(deviceID: device.id)
|
||||||
|
|
||||||
|
if wasRunning {
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[AudioInputManager] Selected device: \(device.name)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the buffer size (512 or 1024)
|
||||||
|
func setBufferSize(_ size: Int) {
|
||||||
|
guard [512, 1024].contains(size) else {
|
||||||
|
print("[AudioInputManager] Invalid buffer size: \(size)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let wasRunning = isRunning
|
||||||
|
if wasRunning {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
currentBufferSize = size
|
||||||
|
|
||||||
|
if wasRunning {
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[AudioInputManager] Buffer size set to: \(size)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts audio capture
|
||||||
|
func start() {
|
||||||
|
guard !isRunning else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Create new audio engine
|
||||||
|
audioEngine = AVAudioEngine()
|
||||||
|
guard let engine = audioEngine else { return }
|
||||||
|
|
||||||
|
inputNode = engine.inputNode
|
||||||
|
|
||||||
|
guard let inputNode = inputNode else {
|
||||||
|
print("[AudioInputManager] No input node available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the input format
|
||||||
|
let inputFormat = inputNode.outputFormat(forBus: 0)
|
||||||
|
|
||||||
|
print("[AudioInputManager] Input format: \(inputFormat)")
|
||||||
|
|
||||||
|
// Install tap on input node
|
||||||
|
let bufferSize = AVAudioFrameCount(currentBufferSize)
|
||||||
|
|
||||||
|
inputNode.installTap(onBus: 0, bufferSize: bufferSize, format: inputFormat) { [weak self] buffer, _ in
|
||||||
|
self?.processingQueue.async {
|
||||||
|
self?.onAudioBuffer?(buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare and start the engine
|
||||||
|
engine.prepare()
|
||||||
|
try engine.start()
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.isRunning = true
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[AudioInputManager] Audio capture started")
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
print("[AudioInputManager] Failed to start audio capture: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops audio capture
|
||||||
|
func stop() {
|
||||||
|
guard isRunning else { return }
|
||||||
|
|
||||||
|
inputNode?.removeTap(onBus: 0)
|
||||||
|
audioEngine?.stop()
|
||||||
|
audioEngine = nil
|
||||||
|
inputNode = nil
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.isRunning = false
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[AudioInputManager] Audio capture stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
|
||||||
|
/// Gets device info for a specific device ID
|
||||||
|
private func getDeviceInfo(deviceID: AudioDeviceID) -> AudioDevice? {
|
||||||
|
// Check if device has input channels
|
||||||
|
var propertyAddress = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyStreamConfiguration,
|
||||||
|
mScope: kAudioDevicePropertyScopeInput,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
|
||||||
|
var dataSize: UInt32 = 0
|
||||||
|
var status = AudioObjectGetPropertyDataSize(deviceID, &propertyAddress, 0, nil, &dataSize)
|
||||||
|
|
||||||
|
guard status == noErr, dataSize > 0 else { return nil }
|
||||||
|
|
||||||
|
let bufferListPointer = UnsafeMutablePointer<AudioBufferList>.allocate(capacity: Int(dataSize))
|
||||||
|
defer { bufferListPointer.deallocate() }
|
||||||
|
|
||||||
|
status = AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &dataSize, bufferListPointer)
|
||||||
|
|
||||||
|
guard status == noErr else { return nil }
|
||||||
|
|
||||||
|
let bufferList = UnsafeMutableAudioBufferListPointer(bufferListPointer)
|
||||||
|
var inputChannelCount: UInt32 = 0
|
||||||
|
for buffer in bufferList {
|
||||||
|
inputChannelCount += buffer.mNumberChannels
|
||||||
|
}
|
||||||
|
|
||||||
|
guard inputChannelCount > 0 else { return nil }
|
||||||
|
|
||||||
|
// Get device UID
|
||||||
|
var uid: CFString = "" as CFString
|
||||||
|
var uidSize = UInt32(MemoryLayout<CFString>.size)
|
||||||
|
propertyAddress.mSelector = kAudioDevicePropertyDeviceUID
|
||||||
|
propertyAddress.mScope = kAudioObjectPropertyScopeGlobal
|
||||||
|
|
||||||
|
status = AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &uidSize, &uid)
|
||||||
|
guard status == noErr else { return nil }
|
||||||
|
|
||||||
|
// Get device name
|
||||||
|
var name: CFString = "" as CFString
|
||||||
|
var nameSize = UInt32(MemoryLayout<CFString>.size)
|
||||||
|
propertyAddress.mSelector = kAudioDevicePropertyDeviceNameCFString
|
||||||
|
|
||||||
|
status = AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &nameSize, &name)
|
||||||
|
guard status == noErr else { return nil }
|
||||||
|
|
||||||
|
// Get manufacturer
|
||||||
|
var manufacturer: CFString = "" as CFString
|
||||||
|
var manufacturerSize = UInt32(MemoryLayout<CFString>.size)
|
||||||
|
propertyAddress.mSelector = kAudioDevicePropertyDeviceManufacturerCFString
|
||||||
|
|
||||||
|
AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &manufacturerSize, &manufacturer)
|
||||||
|
|
||||||
|
return AudioDevice(
|
||||||
|
id: deviceID,
|
||||||
|
uid: uid as String,
|
||||||
|
name: name as String,
|
||||||
|
manufacturer: manufacturer as String,
|
||||||
|
isInput: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the system default input device
|
||||||
|
private func setSystemInputDevice(deviceID: AudioDeviceID) {
|
||||||
|
var deviceIDCopy = deviceID
|
||||||
|
var propertyAddress = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
|
||||||
|
let status = AudioObjectSetPropertyData(
|
||||||
|
AudioObjectID(kAudioObjectSystemObject),
|
||||||
|
&propertyAddress,
|
||||||
|
0,
|
||||||
|
nil,
|
||||||
|
UInt32(MemoryLayout<AudioDeviceID>.size),
|
||||||
|
&deviceIDCopy
|
||||||
|
)
|
||||||
|
|
||||||
|
if status != noErr {
|
||||||
|
print("[AudioInputManager] Failed to set input device: \(status)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets up listener for device changes
|
||||||
|
private func setupDeviceChangeListener() {
|
||||||
|
var propertyAddress = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDevices,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
|
||||||
|
deviceListenerBlock = { [weak self] _, _ in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self?.refreshDeviceList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let block = deviceListenerBlock {
|
||||||
|
AudioObjectAddPropertyListenerBlock(
|
||||||
|
AudioObjectID(kAudioObjectSystemObject),
|
||||||
|
&propertyAddress,
|
||||||
|
DispatchQueue.main,
|
||||||
|
block
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes device change listener
|
||||||
|
private func removeDeviceChangeListener() {
|
||||||
|
guard let block = deviceListenerBlock else { return }
|
||||||
|
|
||||||
|
var propertyAddress = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDevices,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
|
||||||
|
AudioObjectRemovePropertyListenerBlock(
|
||||||
|
AudioObjectID(kAudioObjectSystemObject),
|
||||||
|
&propertyAddress,
|
||||||
|
DispatchQueue.main,
|
||||||
|
block
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,468 @@
|
|||||||
|
//
|
||||||
|
// DSPEngine.swift
|
||||||
|
// PsytranceVisualizer
|
||||||
|
//
|
||||||
|
// Digital Signal Processing engine for audio analysis
|
||||||
|
//
|
||||||
|
|
||||||
|
import Accelerate
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
/// DSP Engine for real-time audio analysis
|
||||||
|
final class DSPEngine {
|
||||||
|
// MARK: - Configuration
|
||||||
|
|
||||||
|
private let sampleRate: Float = 44100.0
|
||||||
|
private var fftSize: Int
|
||||||
|
private let melBandCount: Int = 64
|
||||||
|
private let subBassUpperFreq: Float = 100.0
|
||||||
|
private let historySize: Int = 128
|
||||||
|
|
||||||
|
// MARK: - FFT Setup
|
||||||
|
|
||||||
|
private var fftSetup: vDSP_DFT_Setup?
|
||||||
|
private var window: [Float]
|
||||||
|
private var realPart: [Float]
|
||||||
|
private var imagPart: [Float]
|
||||||
|
private var magnitudes: [Float]
|
||||||
|
|
||||||
|
// MARK: - Mel Filterbank
|
||||||
|
|
||||||
|
private var melFilterbank: [[Float]]
|
||||||
|
private var melOutput: [Float]
|
||||||
|
|
||||||
|
// MARK: - Analysis State
|
||||||
|
|
||||||
|
private var subBassHistory: [Float]
|
||||||
|
private var previousMagnitudes: [Float]
|
||||||
|
private var envelopeValue: Float = 0
|
||||||
|
private var previousEnvelope: Float = 0
|
||||||
|
private var pumpHistory: [Float]
|
||||||
|
private var lastPeakTime: Double = 0
|
||||||
|
private var peakThreshold: Float = 0.3
|
||||||
|
|
||||||
|
// MARK: - Reactivity
|
||||||
|
|
||||||
|
private var reactivity: Float = 0.5
|
||||||
|
private var smoothingFactor: Float = 0.3
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init(bufferSize: Int = 1024) {
|
||||||
|
self.fftSize = bufferSize
|
||||||
|
|
||||||
|
// Initialize FFT arrays
|
||||||
|
self.window = [Float](repeating: 0, count: fftSize)
|
||||||
|
self.realPart = [Float](repeating: 0, count: fftSize)
|
||||||
|
self.imagPart = [Float](repeating: 0, count: fftSize)
|
||||||
|
self.magnitudes = [Float](repeating: 0, count: fftSize / 2)
|
||||||
|
self.previousMagnitudes = [Float](repeating: 0, count: fftSize / 2)
|
||||||
|
|
||||||
|
// Initialize Mel arrays
|
||||||
|
self.melOutput = [Float](repeating: 0, count: melBandCount)
|
||||||
|
self.melFilterbank = []
|
||||||
|
|
||||||
|
// Initialize history arrays
|
||||||
|
self.subBassHistory = [Float](repeating: 0, count: historySize)
|
||||||
|
self.pumpHistory = [Float](repeating: 0, count: 64)
|
||||||
|
|
||||||
|
// Create Hann window
|
||||||
|
vDSP_hann_window(&window, vDSP_Length(fftSize), Int32(vDSP_HANN_NORM))
|
||||||
|
|
||||||
|
// Create FFT setup
|
||||||
|
fftSetup = vDSP_DFT_zop_CreateSetup(
|
||||||
|
nil,
|
||||||
|
vDSP_Length(fftSize),
|
||||||
|
.FORWARD
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build Mel filterbank
|
||||||
|
buildMelFilterbank()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if let setup = fftSetup {
|
||||||
|
vDSP_DFT_DestroySetup(setup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Methods
|
||||||
|
|
||||||
|
/// Sets reactivity value (0.0 - 1.0)
|
||||||
|
func setReactivity(_ value: Float) {
|
||||||
|
reactivity = max(0.0, min(1.0, value))
|
||||||
|
// Adjust smoothing based on reactivity (higher reactivity = less smoothing)
|
||||||
|
smoothingFactor = 0.1 + (1.0 - reactivity) * 0.4
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconfigures for new buffer size
|
||||||
|
func setBufferSize(_ size: Int) {
|
||||||
|
guard size != fftSize else { return }
|
||||||
|
|
||||||
|
fftSize = size
|
||||||
|
|
||||||
|
// Reinitialize arrays
|
||||||
|
window = [Float](repeating: 0, count: fftSize)
|
||||||
|
realPart = [Float](repeating: 0, count: fftSize)
|
||||||
|
imagPart = [Float](repeating: 0, count: fftSize)
|
||||||
|
magnitudes = [Float](repeating: 0, count: fftSize / 2)
|
||||||
|
previousMagnitudes = [Float](repeating: 0, count: fftSize / 2)
|
||||||
|
|
||||||
|
// Recreate window
|
||||||
|
vDSP_hann_window(&window, vDSP_Length(fftSize), Int32(vDSP_HANN_NORM))
|
||||||
|
|
||||||
|
// Recreate FFT setup
|
||||||
|
if let setup = fftSetup {
|
||||||
|
vDSP_DFT_DestroySetup(setup)
|
||||||
|
}
|
||||||
|
fftSetup = vDSP_DFT_zop_CreateSetup(nil, vDSP_Length(fftSize), .FORWARD)
|
||||||
|
|
||||||
|
// Rebuild filterbank
|
||||||
|
buildMelFilterbank()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Processes audio buffer and returns analysis data
|
||||||
|
func process(buffer: AVAudioPCMBuffer) -> AudioAnalysisData {
|
||||||
|
guard let channelData = buffer.floatChannelData else {
|
||||||
|
return .empty
|
||||||
|
}
|
||||||
|
|
||||||
|
let frameCount = Int(buffer.frameLength)
|
||||||
|
let channelCount = Int(buffer.format.channelCount)
|
||||||
|
|
||||||
|
// Extract stereo channels
|
||||||
|
var leftChannel = [Float](repeating: 0, count: frameCount)
|
||||||
|
var rightChannel = [Float](repeating: 0, count: frameCount)
|
||||||
|
|
||||||
|
if channelCount >= 1 {
|
||||||
|
leftChannel = Array(UnsafeBufferPointer(start: channelData[0], count: frameCount))
|
||||||
|
}
|
||||||
|
if channelCount >= 2 {
|
||||||
|
rightChannel = Array(UnsafeBufferPointer(start: channelData[1], count: frameCount))
|
||||||
|
} else {
|
||||||
|
rightChannel = leftChannel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mix to mono for analysis
|
||||||
|
var monoBuffer = [Float](repeating: 0, count: frameCount)
|
||||||
|
vDSP_vadd(leftChannel, 1, rightChannel, 1, &monoBuffer, 1, vDSP_Length(frameCount))
|
||||||
|
var half: Float = 0.5
|
||||||
|
vDSP_vsmul(monoBuffer, 1, &half, &monoBuffer, 1, vDSP_Length(frameCount))
|
||||||
|
|
||||||
|
// Calculate RMS
|
||||||
|
var rmsValue: Float = 0
|
||||||
|
vDSP_rmsqv(monoBuffer, 1, &rmsValue, vDSP_Length(frameCount))
|
||||||
|
|
||||||
|
// Perform FFT
|
||||||
|
let fftMagnitudes = performFFT(monoBuffer)
|
||||||
|
|
||||||
|
// Calculate Mel bands
|
||||||
|
let melBands = calculateMelBands(from: fftMagnitudes)
|
||||||
|
|
||||||
|
// Extract sub-bass
|
||||||
|
let subBassEnergy = calculateSubBassEnergy(from: fftMagnitudes)
|
||||||
|
|
||||||
|
// Update sub-bass history
|
||||||
|
subBassHistory.removeFirst()
|
||||||
|
subBassHistory.append(subBassEnergy)
|
||||||
|
|
||||||
|
// Calculate sidechain envelope and pump detection
|
||||||
|
let (envelope, pumpAmount, isPumping) = detectSidechainPump(subBassEnergy: subBassEnergy)
|
||||||
|
|
||||||
|
// Calculate HNR
|
||||||
|
let hnrRatio = calculateHNR(buffer: monoBuffer)
|
||||||
|
|
||||||
|
// Detect peaks/transients
|
||||||
|
let (isPeak, peakIntensity) = detectPeak(rms: rmsValue)
|
||||||
|
|
||||||
|
// Calculate spectral centroid
|
||||||
|
let spectralCentroid = calculateSpectralCentroid(magnitudes: fftMagnitudes)
|
||||||
|
|
||||||
|
return AudioAnalysisData(
|
||||||
|
fftMagnitudes: fftMagnitudes,
|
||||||
|
melBands: melBands,
|
||||||
|
subBassEnergy: subBassEnergy,
|
||||||
|
subBassHistory: subBassHistory,
|
||||||
|
sidechainEnvelope: envelope,
|
||||||
|
sidechainPumpAmount: pumpAmount,
|
||||||
|
isPumping: isPumping,
|
||||||
|
hnrRatio: hnrRatio,
|
||||||
|
isPeak: isPeak,
|
||||||
|
peakIntensity: peakIntensity,
|
||||||
|
leftChannel: leftChannel,
|
||||||
|
rightChannel: rightChannel,
|
||||||
|
spectralCentroid: spectralCentroid,
|
||||||
|
rmsLevel: rmsValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - FFT
|
||||||
|
|
||||||
|
private func performFFT(_ buffer: [Float]) -> [Float] {
|
||||||
|
guard let setup = fftSetup else { return magnitudes }
|
||||||
|
|
||||||
|
let count = min(buffer.count, fftSize)
|
||||||
|
|
||||||
|
// Apply window
|
||||||
|
var windowedBuffer = [Float](repeating: 0, count: fftSize)
|
||||||
|
for i in 0..<count {
|
||||||
|
windowedBuffer[i] = buffer[i] * window[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare for DFT (separate into real and imaginary)
|
||||||
|
for i in 0..<fftSize {
|
||||||
|
realPart[i] = windowedBuffer[i]
|
||||||
|
imagPart[i] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform DFT
|
||||||
|
var outputReal = [Float](repeating: 0, count: fftSize)
|
||||||
|
var outputImag = [Float](repeating: 0, count: fftSize)
|
||||||
|
|
||||||
|
vDSP_DFT_Execute(setup, realPart, imagPart, &outputReal, &outputImag)
|
||||||
|
|
||||||
|
// Calculate magnitudes
|
||||||
|
let halfSize = fftSize / 2
|
||||||
|
var newMagnitudes = [Float](repeating: 0, count: halfSize)
|
||||||
|
|
||||||
|
for i in 0..<halfSize {
|
||||||
|
let real = outputReal[i]
|
||||||
|
let imag = outputImag[i]
|
||||||
|
newMagnitudes[i] = sqrt(real * real + imag * imag) / Float(fftSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply smoothing
|
||||||
|
for i in 0..<halfSize {
|
||||||
|
magnitudes[i] = magnitudes[i] * smoothingFactor + newMagnitudes[i] * (1.0 - smoothingFactor)
|
||||||
|
}
|
||||||
|
|
||||||
|
previousMagnitudes = magnitudes
|
||||||
|
|
||||||
|
return magnitudes
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mel Filterbank
|
||||||
|
|
||||||
|
private func buildMelFilterbank() {
|
||||||
|
let halfFFT = fftSize / 2
|
||||||
|
let nyquist = sampleRate / 2.0
|
||||||
|
|
||||||
|
// Mel scale conversion
|
||||||
|
func hzToMel(_ hz: Float) -> Float {
|
||||||
|
return 2595.0 * log10(1.0 + hz / 700.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func melToHz(_ mel: Float) -> Float {
|
||||||
|
return 700.0 * (pow(10.0, mel / 2595.0) - 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
let melMin = hzToMel(20.0)
|
||||||
|
let melMax = hzToMel(nyquist)
|
||||||
|
|
||||||
|
// Create mel points
|
||||||
|
var melPoints = [Float](repeating: 0, count: melBandCount + 2)
|
||||||
|
for i in 0..<melBandCount + 2 {
|
||||||
|
melPoints[i] = melMin + Float(i) * (melMax - melMin) / Float(melBandCount + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert back to Hz
|
||||||
|
var hzPoints = melPoints.map { melToHz($0) }
|
||||||
|
|
||||||
|
// Convert to FFT bins
|
||||||
|
var binPoints = hzPoints.map { Int($0 / nyquist * Float(halfFFT)) }
|
||||||
|
|
||||||
|
// Build triangular filters
|
||||||
|
melFilterbank = []
|
||||||
|
|
||||||
|
for m in 1...melBandCount {
|
||||||
|
var filter = [Float](repeating: 0, count: halfFFT)
|
||||||
|
|
||||||
|
let startBin = binPoints[m - 1]
|
||||||
|
let centerBin = binPoints[m]
|
||||||
|
let endBin = binPoints[m + 1]
|
||||||
|
|
||||||
|
// Rising edge
|
||||||
|
for k in startBin..<centerBin {
|
||||||
|
if centerBin != startBin {
|
||||||
|
filter[k] = Float(k - startBin) / Float(centerBin - startBin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Falling edge
|
||||||
|
for k in centerBin..<endBin {
|
||||||
|
if endBin != centerBin {
|
||||||
|
filter[k] = Float(endBin - k) / Float(endBin - centerBin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
melFilterbank.append(filter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateMelBands(from magnitudes: [Float]) -> [Float] {
|
||||||
|
var result = [Float](repeating: 0, count: melBandCount)
|
||||||
|
|
||||||
|
for (i, filter) in melFilterbank.enumerated() {
|
||||||
|
var sum: Float = 0
|
||||||
|
let count = min(filter.count, magnitudes.count)
|
||||||
|
for j in 0..<count {
|
||||||
|
sum += magnitudes[j] * filter[j]
|
||||||
|
}
|
||||||
|
// Apply logarithmic scaling
|
||||||
|
result[i] = log10(1.0 + sum * 10.0) / log10(11.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply smoothing to mel output
|
||||||
|
for i in 0..<melBandCount {
|
||||||
|
melOutput[i] = melOutput[i] * smoothingFactor + result[i] * (1.0 - smoothingFactor)
|
||||||
|
}
|
||||||
|
|
||||||
|
return melOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sub-Bass Analysis
|
||||||
|
|
||||||
|
private func calculateSubBassEnergy(from magnitudes: [Float]) -> Float {
|
||||||
|
let binFrequency = sampleRate / Float(fftSize)
|
||||||
|
let subBassBinCount = Int(subBassUpperFreq / binFrequency)
|
||||||
|
|
||||||
|
guard subBassBinCount > 0, magnitudes.count >= subBassBinCount else { return 0 }
|
||||||
|
|
||||||
|
var sum: Float = 0
|
||||||
|
for i in 0..<subBassBinCount {
|
||||||
|
sum += magnitudes[i] * magnitudes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
let rms = sqrt(sum / Float(subBassBinCount))
|
||||||
|
|
||||||
|
// Normalize and apply gain
|
||||||
|
let normalized = min(1.0, rms * 5.0 * (1.0 + reactivity))
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sidechain Pump Detection
|
||||||
|
|
||||||
|
private func detectSidechainPump(subBassEnergy: Float) -> (envelope: Float, pumpAmount: Float, isPumping: Bool) {
|
||||||
|
// Envelope follower with fast attack, slow release
|
||||||
|
let attackTime: Float = 0.005 // 5ms attack
|
||||||
|
let releaseTime: Float = 0.15 // 150ms release
|
||||||
|
|
||||||
|
let attackCoeff = exp(-1.0 / (sampleRate * attackTime))
|
||||||
|
let releaseCoeff = exp(-1.0 / (sampleRate * releaseTime))
|
||||||
|
|
||||||
|
if subBassEnergy > envelopeValue {
|
||||||
|
envelopeValue = attackCoeff * envelopeValue + (1.0 - attackCoeff) * subBassEnergy
|
||||||
|
} else {
|
||||||
|
envelopeValue = releaseCoeff * envelopeValue + (1.0 - releaseCoeff) * subBassEnergy
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update pump history
|
||||||
|
pumpHistory.removeFirst()
|
||||||
|
pumpHistory.append(envelopeValue)
|
||||||
|
|
||||||
|
// Analyze pump periodicity
|
||||||
|
var pumpAmount: Float = 0
|
||||||
|
var isPumping = false
|
||||||
|
|
||||||
|
// Look for characteristic pump pattern (rise and fall)
|
||||||
|
let derivative = envelopeValue - previousEnvelope
|
||||||
|
previousEnvelope = envelopeValue
|
||||||
|
|
||||||
|
// Detect pump by finding periodic envelope variations
|
||||||
|
if pumpHistory.count >= 32 {
|
||||||
|
let recent = Array(pumpHistory.suffix(32))
|
||||||
|
var variance: Float = 0
|
||||||
|
let mean = recent.reduce(0, +) / Float(recent.count)
|
||||||
|
|
||||||
|
for value in recent {
|
||||||
|
variance += (value - mean) * (value - mean)
|
||||||
|
}
|
||||||
|
variance /= Float(recent.count)
|
||||||
|
|
||||||
|
// Higher variance = more pumping
|
||||||
|
pumpAmount = min(1.0, sqrt(variance) * 4.0)
|
||||||
|
isPumping = pumpAmount > 0.3 && abs(derivative) > 0.02
|
||||||
|
}
|
||||||
|
|
||||||
|
return (envelopeValue, pumpAmount, isPumping)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HNR Calculation
|
||||||
|
|
||||||
|
private func calculateHNR(buffer: [Float]) -> Float {
|
||||||
|
// Use autocorrelation to estimate harmonicity
|
||||||
|
let frameSize = min(buffer.count, 512)
|
||||||
|
var autocorr = [Float](repeating: 0, count: frameSize)
|
||||||
|
|
||||||
|
// Compute autocorrelation
|
||||||
|
vDSP_conv(buffer, 1, buffer, 1, &autocorr, 1, vDSP_Length(frameSize), vDSP_Length(frameSize))
|
||||||
|
|
||||||
|
// Find the peak in autocorrelation (excluding lag 0)
|
||||||
|
let minLag = 20 // Minimum lag to avoid DC component
|
||||||
|
let maxLag = min(frameSize - 1, 400) // Maximum lag
|
||||||
|
|
||||||
|
guard maxLag > minLag else { return 0.5 }
|
||||||
|
|
||||||
|
var maxValue: Float = 0
|
||||||
|
var maxIndex: vDSP_Length = 0
|
||||||
|
|
||||||
|
let searchRange = Array(autocorr[minLag...maxLag])
|
||||||
|
vDSP_maxvi(searchRange, 1, &maxValue, &maxIndex, vDSP_Length(searchRange.count))
|
||||||
|
|
||||||
|
// Calculate HNR as ratio of peak to first value
|
||||||
|
let noiseFloor = autocorr.suffix(from: maxLag).reduce(0) { $0 + abs($1) } / Float(frameSize - maxLag)
|
||||||
|
|
||||||
|
let harmonicPower = maxValue
|
||||||
|
let noisePower = max(noiseFloor, 0.0001)
|
||||||
|
|
||||||
|
// Convert to 0-1 range
|
||||||
|
let hnr = harmonicPower / (harmonicPower + noisePower)
|
||||||
|
|
||||||
|
return max(0.0, min(1.0, hnr))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Peak Detection
|
||||||
|
|
||||||
|
private var previousRMS: Float = 0
|
||||||
|
private var rmsHistory: [Float] = Array(repeating: 0, count: 16)
|
||||||
|
|
||||||
|
private func detectPeak(rms: Float) -> (isPeak: Bool, intensity: Float) {
|
||||||
|
// Update history
|
||||||
|
rmsHistory.removeFirst()
|
||||||
|
rmsHistory.append(rms)
|
||||||
|
|
||||||
|
// Calculate moving average
|
||||||
|
let average = rmsHistory.reduce(0, +) / Float(rmsHistory.count)
|
||||||
|
|
||||||
|
// Detect sudden increase
|
||||||
|
let increase = rms - previousRMS
|
||||||
|
let threshold = average * (0.5 + reactivity * 0.5)
|
||||||
|
|
||||||
|
previousRMS = rms
|
||||||
|
|
||||||
|
let isPeak = increase > threshold && rms > average * 1.5
|
||||||
|
let intensity = isPeak ? min(1.0, increase / max(average, 0.01) * 2.0) : 0
|
||||||
|
|
||||||
|
return (isPeak, intensity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Spectral Centroid
|
||||||
|
|
||||||
|
private func calculateSpectralCentroid(magnitudes: [Float]) -> Float {
|
||||||
|
var weightedSum: Float = 0
|
||||||
|
var sum: Float = 0
|
||||||
|
|
||||||
|
for (i, mag) in magnitudes.enumerated() {
|
||||||
|
weightedSum += Float(i) * mag
|
||||||
|
sum += mag
|
||||||
|
}
|
||||||
|
|
||||||
|
guard sum > 0 else { return 0.5 }
|
||||||
|
|
||||||
|
let centroid = weightedSum / sum
|
||||||
|
let normalized = centroid / Float(magnitudes.count)
|
||||||
|
|
||||||
|
return max(0.0, min(1.0, normalized))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
//
|
||||||
|
// AppSettings.swift
|
||||||
|
// PsytranceVisualizer
|
||||||
|
//
|
||||||
|
// Persistent application settings
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Application settings that are persisted between sessions
|
||||||
|
struct AppSettings: Codable {
|
||||||
|
/// Selected audio input device UID
|
||||||
|
var selectedAudioDeviceUID: String?
|
||||||
|
|
||||||
|
/// Audio buffer size (512 or 1024 samples)
|
||||||
|
var bufferSize: Int
|
||||||
|
|
||||||
|
/// Last used visualization mode (1-8)
|
||||||
|
var lastVisualizationMode: Int
|
||||||
|
|
||||||
|
/// Reactivity slider value (0.0 - 1.0)
|
||||||
|
var reactivity: Float
|
||||||
|
|
||||||
|
/// Whether app was in fullscreen mode
|
||||||
|
var isFullscreen: Bool
|
||||||
|
|
||||||
|
/// Last window frame (for restoration)
|
||||||
|
var windowFrame: CodableRect?
|
||||||
|
|
||||||
|
/// Volume/gain adjustment
|
||||||
|
var inputGain: Float
|
||||||
|
|
||||||
|
/// Whether to show FPS counter
|
||||||
|
var showFPS: Bool
|
||||||
|
|
||||||
|
/// Default settings
|
||||||
|
static var `default`: AppSettings {
|
||||||
|
AppSettings(
|
||||||
|
selectedAudioDeviceUID: nil,
|
||||||
|
bufferSize: 1024,
|
||||||
|
lastVisualizationMode: 1,
|
||||||
|
reactivity: 0.5,
|
||||||
|
isFullscreen: false,
|
||||||
|
windowFrame: nil,
|
||||||
|
inputGain: 1.0,
|
||||||
|
showFPS: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Available buffer sizes
|
||||||
|
static let availableBufferSizes = [512, 1024]
|
||||||
|
|
||||||
|
/// Validates and clamps settings to valid ranges
|
||||||
|
mutating func validate() {
|
||||||
|
// Clamp buffer size to valid options
|
||||||
|
if !AppSettings.availableBufferSizes.contains(bufferSize) {
|
||||||
|
bufferSize = 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp visualization mode
|
||||||
|
if lastVisualizationMode < 1 || lastVisualizationMode > 8 {
|
||||||
|
lastVisualizationMode = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp reactivity
|
||||||
|
reactivity = max(0.0, min(1.0, reactivity))
|
||||||
|
|
||||||
|
// Clamp input gain
|
||||||
|
inputGain = max(0.0, min(2.0, inputGain))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Codable wrapper for CGRect
|
||||||
|
struct CodableRect: Codable {
|
||||||
|
var x: Double
|
||||||
|
var y: Double
|
||||||
|
var width: Double
|
||||||
|
var height: Double
|
||||||
|
|
||||||
|
init(from rect: CGRect) {
|
||||||
|
self.x = Double(rect.origin.x)
|
||||||
|
self.y = Double(rect.origin.y)
|
||||||
|
self.width = Double(rect.size.width)
|
||||||
|
self.height = Double(rect.size.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cgRect: CGRect {
|
||||||
|
CGRect(x: x, y: y, width: width, height: height)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
//
|
||||||
|
// AudioAnalysisData.swift
|
||||||
|
// PsytranceVisualizer
|
||||||
|
//
|
||||||
|
// Audio analysis data structure containing all DSP results
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Contains all audio analysis data computed by DSPEngine
|
||||||
|
struct AudioAnalysisData {
|
||||||
|
// MARK: - FFT Data
|
||||||
|
|
||||||
|
/// Raw FFT magnitude spectrum
|
||||||
|
var fftMagnitudes: [Float]
|
||||||
|
|
||||||
|
// MARK: - Mel Spectrogram
|
||||||
|
|
||||||
|
/// 64 Mel frequency bands
|
||||||
|
var melBands: [Float]
|
||||||
|
|
||||||
|
// MARK: - Sub-Bass Analysis
|
||||||
|
|
||||||
|
/// RMS energy below 100Hz (0.0 - 1.0)
|
||||||
|
var subBassEnergy: Float
|
||||||
|
|
||||||
|
/// History buffer for time-based visualization
|
||||||
|
var subBassHistory: [Float]
|
||||||
|
|
||||||
|
// MARK: - Sidechain Detection
|
||||||
|
|
||||||
|
/// Current envelope follower value (0.0 - 1.0)
|
||||||
|
var sidechainEnvelope: Float
|
||||||
|
|
||||||
|
/// Detected pumping amount (0.0 - 1.0)
|
||||||
|
var sidechainPumpAmount: Float
|
||||||
|
|
||||||
|
/// Whether pump is currently active
|
||||||
|
var isPumping: Bool
|
||||||
|
|
||||||
|
// MARK: - Harmonic-to-Noise Ratio
|
||||||
|
|
||||||
|
/// HNR ratio (0.0 = noise, 1.0 = pure harmonic)
|
||||||
|
var hnrRatio: Float
|
||||||
|
|
||||||
|
// MARK: - Transient Detection
|
||||||
|
|
||||||
|
/// Whether a transient peak was detected
|
||||||
|
var isPeak: Bool
|
||||||
|
|
||||||
|
/// Intensity of the detected peak (0.0 - 1.0)
|
||||||
|
var peakIntensity: Float
|
||||||
|
|
||||||
|
// MARK: - Stereo Channels
|
||||||
|
|
||||||
|
/// Left channel samples
|
||||||
|
var leftChannel: [Float]
|
||||||
|
|
||||||
|
/// Right channel samples
|
||||||
|
var rightChannel: [Float]
|
||||||
|
|
||||||
|
// MARK: - Additional Analysis
|
||||||
|
|
||||||
|
/// Spectral centroid (brightness) normalized 0.0 - 1.0
|
||||||
|
var spectralCentroid: Float
|
||||||
|
|
||||||
|
/// Overall RMS level
|
||||||
|
var rmsLevel: Float
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
/// Creates an empty AudioAnalysisData with default values
|
||||||
|
static var empty: AudioAnalysisData {
|
||||||
|
AudioAnalysisData(
|
||||||
|
fftMagnitudes: [],
|
||||||
|
melBands: Array(repeating: 0, count: 64),
|
||||||
|
subBassEnergy: 0,
|
||||||
|
subBassHistory: [],
|
||||||
|
sidechainEnvelope: 0,
|
||||||
|
sidechainPumpAmount: 0,
|
||||||
|
isPumping: false,
|
||||||
|
hnrRatio: 0.5,
|
||||||
|
isPeak: false,
|
||||||
|
peakIntensity: 0,
|
||||||
|
leftChannel: [],
|
||||||
|
rightChannel: [],
|
||||||
|
spectralCentroid: 0.5,
|
||||||
|
rmsLevel: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates AudioAnalysisData with specified FFT size
|
||||||
|
static func create(fftSize: Int) -> AudioAnalysisData {
|
||||||
|
AudioAnalysisData(
|
||||||
|
fftMagnitudes: Array(repeating: 0, count: fftSize / 2),
|
||||||
|
melBands: Array(repeating: 0, count: 64),
|
||||||
|
subBassEnergy: 0,
|
||||||
|
subBassHistory: Array(repeating: 0, count: 128),
|
||||||
|
sidechainEnvelope: 0,
|
||||||
|
sidechainPumpAmount: 0,
|
||||||
|
isPumping: false,
|
||||||
|
hnrRatio: 0.5,
|
||||||
|
isPeak: false,
|
||||||
|
peakIntensity: 0,
|
||||||
|
leftChannel: [],
|
||||||
|
rightChannel: [],
|
||||||
|
spectralCentroid: 0.5,
|
||||||
|
rmsLevel: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
//
|
||||||
|
// VisualizationMode.swift
|
||||||
|
// PsytranceVisualizer
|
||||||
|
//
|
||||||
|
// Enumeration of all available visualization modes
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Available visualization modes, accessible via keyboard shortcuts 1-8
|
||||||
|
enum VisualizationMode: Int, CaseIterable, Codable {
|
||||||
|
case fftClassic = 1
|
||||||
|
case melSpectrogram = 2
|
||||||
|
case subBass = 3
|
||||||
|
case sidechainPump = 4
|
||||||
|
case hnr = 5
|
||||||
|
case mandelbrot = 6
|
||||||
|
case tunnelWarp = 7
|
||||||
|
case dmtGeometry = 8
|
||||||
|
|
||||||
|
/// Display name for UI
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .fftClassic:
|
||||||
|
return "FFT Classic"
|
||||||
|
case .melSpectrogram:
|
||||||
|
return "Mel Spektrogramm"
|
||||||
|
case .subBass:
|
||||||
|
return "Sub-Bass (<100Hz)"
|
||||||
|
case .sidechainPump:
|
||||||
|
return "Sidechain Pump"
|
||||||
|
case .hnr:
|
||||||
|
return "Harmonic/Noise"
|
||||||
|
case .mandelbrot:
|
||||||
|
return "Mandelbrot"
|
||||||
|
case .tunnelWarp:
|
||||||
|
return "Tunnel Warp"
|
||||||
|
case .dmtGeometry:
|
||||||
|
return "DMT Geometry"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keyboard shortcut (1-8)
|
||||||
|
var shortcut: String {
|
||||||
|
return "\(self.rawValue)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metal shader function name
|
||||||
|
var shaderFunctionName: String {
|
||||||
|
switch self {
|
||||||
|
case .fftClassic:
|
||||||
|
return "fftClassicFragment"
|
||||||
|
case .melSpectrogram:
|
||||||
|
return "melSpectrogramFragment"
|
||||||
|
case .subBass:
|
||||||
|
return "subBassFragment"
|
||||||
|
case .sidechainPump:
|
||||||
|
return "sidechainPumpFragment"
|
||||||
|
case .hnr:
|
||||||
|
return "hnrFragment"
|
||||||
|
case .mandelbrot:
|
||||||
|
return "mandelbrotFragment"
|
||||||
|
case .tunnelWarp:
|
||||||
|
return "tunnelWarpFragment"
|
||||||
|
case .dmtGeometry:
|
||||||
|
return "dmtGeometryFragment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Description of the visualization
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .fftClassic:
|
||||||
|
return "Classic frequency spectrum bars with glow effects"
|
||||||
|
case .melSpectrogram:
|
||||||
|
return "64-band Mel spectrogram with scrolling waterfall display"
|
||||||
|
case .subBass:
|
||||||
|
return "Pulsating rings visualizing sub-bass energy below 100Hz"
|
||||||
|
case .sidechainPump:
|
||||||
|
return "Breathing zoom effect synchronized to sidechain pumping"
|
||||||
|
case .hnr:
|
||||||
|
return "Harmonic vs noise visualization with geometric shapes"
|
||||||
|
case .mandelbrot:
|
||||||
|
return "Audio-reactive Mandelbrot fractal with zoom and color cycling"
|
||||||
|
case .tunnelWarp:
|
||||||
|
return "Infinite tunnel effect with warp distortion"
|
||||||
|
case .dmtGeometry:
|
||||||
|
return "Sacred geometry patterns: Flower of Life, Metatron's Cube, Sri Yantra"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates mode from keyboard key code
|
||||||
|
static func fromKeyCode(_ keyCode: UInt16) -> VisualizationMode? {
|
||||||
|
// Key codes for 1-8 on US keyboard
|
||||||
|
let keyCodes: [UInt16: Int] = [
|
||||||
|
18: 1, // 1
|
||||||
|
19: 2, // 2
|
||||||
|
20: 3, // 3
|
||||||
|
21: 4, // 4
|
||||||
|
23: 5, // 5
|
||||||
|
22: 6, // 6
|
||||||
|
26: 7, // 7
|
||||||
|
28: 8 // 8
|
||||||
|
]
|
||||||
|
|
||||||
|
guard let modeNumber = keyCodes[keyCode] else { return nil }
|
||||||
|
return VisualizationMode(rawValue: modeNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// swift-tools-version: 5.9
|
||||||
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "PsytranceVisualizer",
|
||||||
|
platforms: [
|
||||||
|
.macOS(.v13)
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
.executable(
|
||||||
|
name: "PsytranceVisualizer",
|
||||||
|
targets: ["PsytranceVisualizer"]
|
||||||
|
)
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.executableTarget(
|
||||||
|
name: "PsytranceVisualizer",
|
||||||
|
path: ".",
|
||||||
|
exclude: [
|
||||||
|
"Package.swift",
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
|
sources: [
|
||||||
|
"App",
|
||||||
|
"Audio",
|
||||||
|
"Models",
|
||||||
|
"Rendering",
|
||||||
|
"UI",
|
||||||
|
"Utilities"
|
||||||
|
],
|
||||||
|
resources: [
|
||||||
|
.process("Resources")
|
||||||
|
],
|
||||||
|
swiftSettings: [
|
||||||
|
.unsafeFlags(["-enable-bare-slash-regex"])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 56;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
1100000000000001 /* PsytranceVisualizerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000001 /* PsytranceVisualizerApp.swift */; };
|
||||||
|
1100000000000002 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000002 /* AppDelegate.swift */; };
|
||||||
|
1100000000000003 /* AudioInputManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000003 /* AudioInputManager.swift */; };
|
||||||
|
1100000000000004 /* DSPEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000004 /* DSPEngine.swift */; };
|
||||||
|
1100000000000005 /* AudioAnalysisData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000005 /* AudioAnalysisData.swift */; };
|
||||||
|
1100000000000006 /* VisualizationMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000006 /* VisualizationMode.swift */; };
|
||||||
|
1100000000000007 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000007 /* AppSettings.swift */; };
|
||||||
|
1100000000000008 /* MetalRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000008 /* MetalRenderer.swift */; };
|
||||||
|
1100000000000009 /* Common.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000009 /* Common.metal */; };
|
||||||
|
1100000000000010 /* FFTClassicShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000010 /* FFTClassicShader.metal */; };
|
||||||
|
1100000000000011 /* MelSpectrogramShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000011 /* MelSpectrogramShader.metal */; };
|
||||||
|
1100000000000012 /* SubBassShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000012 /* SubBassShader.metal */; };
|
||||||
|
1100000000000013 /* SidechainPumpShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000013 /* SidechainPumpShader.metal */; };
|
||||||
|
1100000000000014 /* HNRShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000014 /* HNRShader.metal */; };
|
||||||
|
1100000000000015 /* MandelbrotShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000015 /* MandelbrotShader.metal */; };
|
||||||
|
1100000000000016 /* TunnelWarpShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000016 /* TunnelWarpShader.metal */; };
|
||||||
|
1100000000000017 /* DMTGeometryShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000017 /* DMTGeometryShader.metal */; };
|
||||||
|
1100000000000018 /* MainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000018 /* MainWindow.swift */; };
|
||||||
|
1100000000000019 /* ControlPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000019 /* ControlPanel.swift */; };
|
||||||
|
1100000000000020 /* VisualizerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000020 /* VisualizerView.swift */; };
|
||||||
|
1100000000000021 /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000021 /* SettingsManager.swift */; };
|
||||||
|
1100000000000022 /* ColorPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000022 /* ColorPalette.swift */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
2000000000000001 /* PsytranceVisualizer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PsytranceVisualizer.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
2100000000000001 /* PsytranceVisualizerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PsytranceVisualizerApp.swift; sourceTree = "<group>"; };
|
||||||
|
2100000000000002 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
2100000000000003 /* AudioInputManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioInputManager.swift; sourceTree = "<group>"; };
|
||||||
|
2100000000000004 /* DSPEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DSPEngine.swift; sourceTree = "<group>"; };
|
||||||
|
2100000000000005 /* AudioAnalysisData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioAnalysisData.swift; sourceTree = "<group>"; };
|
||||||
|
2100000000000006 /* VisualizationMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualizationMode.swift; sourceTree = "<group>"; };
|
||||||
|
2100000000000007 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; };
|
||||||
|
2100000000000008 /* MetalRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetalRenderer.swift; sourceTree = "<group>"; };
|
||||||
|
2100000000000009 /* Common.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = Common.metal; sourceTree = "<group>"; };
|
||||||
|
2100000000000010 /* FFTClassicShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = FFTClassicShader.metal; sourceTree = "<group>"; };
|
||||||
|
2100000000000011 /* MelSpectrogramShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = MelSpectrogramShader.metal; sourceTree = "<group>"; };
|
||||||
|
2100000000000012 /* SubBassShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = SubBassShader.metal; sourceTree = "<group>"; };
|
||||||
|
2100000000000013 /* SidechainPumpShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = SidechainPumpShader.metal; sourceTree = "<group>"; };
|
||||||
|
2100000000000014 /* HNRShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = HNRShader.metal; sourceTree = "<group>"; };
|
||||||
|
2100000000000015 /* MandelbrotShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = MandelbrotShader.metal; sourceTree = "<group>"; };
|
||||||
|
2100000000000016 /* TunnelWarpShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = TunnelWarpShader.metal; sourceTree = "<group>"; };
|
||||||
|
2100000000000017 /* DMTGeometryShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = DMTGeometryShader.metal; sourceTree = "<group>"; };
|
||||||
|
2100000000000018 /* MainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindow.swift; sourceTree = "<group>"; };
|
||||||
|
2100000000000019 /* ControlPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlPanel.swift; sourceTree = "<group>"; };
|
||||||
|
2100000000000020 /* VisualizerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualizerView.swift; sourceTree = "<group>"; };
|
||||||
|
2100000000000021 /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = "<group>"; };
|
||||||
|
2100000000000022 /* ColorPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPalette.swift; sourceTree = "<group>"; };
|
||||||
|
2100000000000023 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
2100000000000024 /* PsytranceVisualizer.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PsytranceVisualizer.entitlements; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
3000000000000001 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
4000000000000001 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
4000000000000002 /* App */,
|
||||||
|
4000000000000003 /* Audio */,
|
||||||
|
4000000000000004 /* Models */,
|
||||||
|
4000000000000005 /* Rendering */,
|
||||||
|
4000000000000007 /* UI */,
|
||||||
|
4000000000000008 /* Utilities */,
|
||||||
|
4000000000000009 /* Resources */,
|
||||||
|
4000000000000010 /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
4000000000000002 /* App */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
2100000000000001 /* PsytranceVisualizerApp.swift */,
|
||||||
|
2100000000000002 /* AppDelegate.swift */,
|
||||||
|
);
|
||||||
|
path = App;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
4000000000000003 /* Audio */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
2100000000000003 /* AudioInputManager.swift */,
|
||||||
|
2100000000000004 /* DSPEngine.swift */,
|
||||||
|
);
|
||||||
|
path = Audio;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
4000000000000004 /* Models */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
2100000000000005 /* AudioAnalysisData.swift */,
|
||||||
|
2100000000000006 /* VisualizationMode.swift */,
|
||||||
|
2100000000000007 /* AppSettings.swift */,
|
||||||
|
);
|
||||||
|
path = Models;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
4000000000000005 /* Rendering */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
2100000000000008 /* MetalRenderer.swift */,
|
||||||
|
4000000000000006 /* Shaders */,
|
||||||
|
);
|
||||||
|
path = Rendering;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
4000000000000006 /* Shaders */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
2100000000000009 /* Common.metal */,
|
||||||
|
2100000000000010 /* FFTClassicShader.metal */,
|
||||||
|
2100000000000011 /* MelSpectrogramShader.metal */,
|
||||||
|
2100000000000012 /* SubBassShader.metal */,
|
||||||
|
2100000000000013 /* SidechainPumpShader.metal */,
|
||||||
|
2100000000000014 /* HNRShader.metal */,
|
||||||
|
2100000000000015 /* MandelbrotShader.metal */,
|
||||||
|
2100000000000016 /* TunnelWarpShader.metal */,
|
||||||
|
2100000000000017 /* DMTGeometryShader.metal */,
|
||||||
|
);
|
||||||
|
path = Shaders;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
4000000000000007 /* UI */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
2100000000000018 /* MainWindow.swift */,
|
||||||
|
2100000000000019 /* ControlPanel.swift */,
|
||||||
|
2100000000000020 /* VisualizerView.swift */,
|
||||||
|
);
|
||||||
|
path = UI;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
4000000000000008 /* Utilities */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
2100000000000021 /* SettingsManager.swift */,
|
||||||
|
2100000000000022 /* ColorPalette.swift */,
|
||||||
|
);
|
||||||
|
path = Utilities;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
4000000000000009 /* Resources */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
2100000000000023 /* Info.plist */,
|
||||||
|
2100000000000024 /* PsytranceVisualizer.entitlements */,
|
||||||
|
);
|
||||||
|
path = Resources;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
4000000000000010 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
2000000000000001 /* PsytranceVisualizer.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
5000000000000001 /* PsytranceVisualizer */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 6000000000000003 /* Build configuration list for PBXNativeTarget "PsytranceVisualizer" */;
|
||||||
|
buildPhases = (
|
||||||
|
5000000000000002 /* Sources */,
|
||||||
|
3000000000000001 /* Frameworks */,
|
||||||
|
5000000000000003 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = PsytranceVisualizer;
|
||||||
|
productName = PsytranceVisualizer;
|
||||||
|
productReference = 2000000000000001 /* PsytranceVisualizer.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
0000000000000001 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = 1;
|
||||||
|
LastSwiftUpdateCheck = 1500;
|
||||||
|
LastUpgradeCheck = 1500;
|
||||||
|
TargetAttributes = {
|
||||||
|
5000000000000001 = {
|
||||||
|
CreatedOnToolsVersion = 15.0;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = 6000000000000001 /* Build configuration list for PBXProject "PsytranceVisualizer" */;
|
||||||
|
compatibilityVersion = "Xcode 14.0";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = 4000000000000001;
|
||||||
|
productRefGroup = 4000000000000010 /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
5000000000000001 /* PsytranceVisualizer */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
5000000000000003 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
5000000000000002 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
1100000000000001 /* PsytranceVisualizerApp.swift in Sources */,
|
||||||
|
1100000000000002 /* AppDelegate.swift in Sources */,
|
||||||
|
1100000000000003 /* AudioInputManager.swift in Sources */,
|
||||||
|
1100000000000004 /* DSPEngine.swift in Sources */,
|
||||||
|
1100000000000005 /* AudioAnalysisData.swift in Sources */,
|
||||||
|
1100000000000006 /* VisualizationMode.swift in Sources */,
|
||||||
|
1100000000000007 /* AppSettings.swift in Sources */,
|
||||||
|
1100000000000008 /* MetalRenderer.swift in Sources */,
|
||||||
|
1100000000000009 /* Common.metal in Sources */,
|
||||||
|
1100000000000010 /* FFTClassicShader.metal in Sources */,
|
||||||
|
1100000000000011 /* MelSpectrogramShader.metal in Sources */,
|
||||||
|
1100000000000012 /* SubBassShader.metal in Sources */,
|
||||||
|
1100000000000013 /* SidechainPumpShader.metal in Sources */,
|
||||||
|
1100000000000014 /* HNRShader.metal in Sources */,
|
||||||
|
1100000000000015 /* MandelbrotShader.metal in Sources */,
|
||||||
|
1100000000000016 /* TunnelWarpShader.metal in Sources */,
|
||||||
|
1100000000000017 /* DMTGeometryShader.metal in Sources */,
|
||||||
|
1100000000000018 /* MainWindow.swift in Sources */,
|
||||||
|
1100000000000019 /* ControlPanel.swift in Sources */,
|
||||||
|
1100000000000020 /* VisualizerView.swift in Sources */,
|
||||||
|
1100000000000021 /* SettingsManager.swift in Sources */,
|
||||||
|
1100000000000022 /* ColorPalette.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
6100000000000001 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"DEBUG=1",
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = macosx;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
6100000000000002 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
SDKROOT = macosx;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
6100000000000003 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Resources/PsytranceVisualizer.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = Resources/Info.plist;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Psytrance Visualizer needs access to your audio input to visualize music in real-time.";
|
||||||
|
INFOPLIST_KEY_NSPrincipalClass = NSApplication;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.psytrance.visualizer;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
6100000000000004 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Resources/PsytranceVisualizer.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = Resources/Info.plist;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Psytrance Visualizer needs access to your audio input to visualize music in real-time.";
|
||||||
|
INFOPLIST_KEY_NSPrincipalClass = NSApplication;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.psytrance.visualizer;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
6000000000000001 /* Build configuration list for PBXProject "PsytranceVisualizer" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
6100000000000001 /* Debug */,
|
||||||
|
6100000000000002 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
6000000000000003 /* Build configuration list for PBXNativeTarget "PsytranceVisualizer" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
6100000000000003 /* Debug */,
|
||||||
|
6100000000000004 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = 0000000000000001 /* Project object */;
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
//
|
||||||
|
// MetalRenderer.swift
|
||||||
|
// PsytranceVisualizer
|
||||||
|
//
|
||||||
|
// Metal-based renderer for all visualization modes
|
||||||
|
//
|
||||||
|
|
||||||
|
import MetalKit
|
||||||
|
import simd
|
||||||
|
|
||||||
|
/// Uniform data passed to all shaders
|
||||||
|
struct ShaderUniforms {
|
||||||
|
var time: Float
|
||||||
|
var resolution: SIMD2<Float>
|
||||||
|
var reactivity: Float
|
||||||
|
|
||||||
|
// Audio analysis data
|
||||||
|
var subBassEnergy: Float
|
||||||
|
var sidechainPump: Float
|
||||||
|
var sidechainEnvelope: Float
|
||||||
|
var hnrRatio: Float
|
||||||
|
var isPeak: Float
|
||||||
|
var peakIntensity: Float
|
||||||
|
var spectralCentroid: Float
|
||||||
|
var rmsLevel: Float
|
||||||
|
|
||||||
|
// Visualization mode (1-8)
|
||||||
|
var mode: Int32
|
||||||
|
|
||||||
|
// Padding for Metal alignment
|
||||||
|
var padding: SIMD2<Float> = .zero
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metal renderer managing all visualization shaders
|
||||||
|
final class MetalRenderer: NSObject, ObservableObject {
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
private let device: MTLDevice
|
||||||
|
private let commandQueue: MTLCommandQueue
|
||||||
|
private var pipelineStates: [VisualizationMode: MTLRenderPipelineState] = [:]
|
||||||
|
private var currentPipelineState: MTLRenderPipelineState?
|
||||||
|
|
||||||
|
@Published private(set) var currentMode: VisualizationMode = .fftClassic
|
||||||
|
|
||||||
|
// MARK: - Buffers
|
||||||
|
|
||||||
|
private var uniformBuffer: MTLBuffer?
|
||||||
|
private var fftBuffer: MTLBuffer?
|
||||||
|
private var melBuffer: MTLBuffer?
|
||||||
|
private var subBassHistoryBuffer: MTLBuffer?
|
||||||
|
|
||||||
|
// MARK: - State
|
||||||
|
|
||||||
|
private var startTime: CFAbsoluteTime
|
||||||
|
private var uniforms = ShaderUniforms(
|
||||||
|
time: 0,
|
||||||
|
resolution: SIMD2<Float>(1920, 1080),
|
||||||
|
reactivity: 0.5,
|
||||||
|
subBassEnergy: 0,
|
||||||
|
sidechainPump: 0,
|
||||||
|
sidechainEnvelope: 0,
|
||||||
|
hnrRatio: 0.5,
|
||||||
|
isPeak: 0,
|
||||||
|
peakIntensity: 0,
|
||||||
|
spectralCentroid: 0.5,
|
||||||
|
rmsLevel: 0,
|
||||||
|
mode: 1
|
||||||
|
)
|
||||||
|
|
||||||
|
private var audioData: AudioAnalysisData = .empty
|
||||||
|
|
||||||
|
// MARK: - Constants
|
||||||
|
|
||||||
|
private let maxFFTSize = 1024
|
||||||
|
private let melBandCount = 64
|
||||||
|
private let historySize = 128
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init?(device: MTLDevice) {
|
||||||
|
guard let queue = device.makeCommandQueue() else {
|
||||||
|
print("[MetalRenderer] Failed to create command queue")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
self.device = device
|
||||||
|
self.commandQueue = queue
|
||||||
|
self.startTime = CFAbsoluteTimeGetCurrent()
|
||||||
|
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
createBuffers()
|
||||||
|
loadShaders()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Methods
|
||||||
|
|
||||||
|
/// Sets the current visualization mode
|
||||||
|
func setVisualizationMode(_ mode: VisualizationMode) {
|
||||||
|
currentMode = mode
|
||||||
|
currentPipelineState = pipelineStates[mode]
|
||||||
|
uniforms.mode = Int32(mode.rawValue)
|
||||||
|
print("[MetalRenderer] Mode changed to: \(mode.displayName)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates audio analysis data
|
||||||
|
func updateAudioData(_ data: AudioAnalysisData) {
|
||||||
|
audioData = data
|
||||||
|
|
||||||
|
// Update uniforms
|
||||||
|
uniforms.subBassEnergy = data.subBassEnergy
|
||||||
|
uniforms.sidechainPump = data.sidechainPumpAmount
|
||||||
|
uniforms.sidechainEnvelope = data.sidechainEnvelope
|
||||||
|
uniforms.hnrRatio = data.hnrRatio
|
||||||
|
uniforms.isPeak = data.isPeak ? 1.0 : 0.0
|
||||||
|
uniforms.peakIntensity = data.peakIntensity
|
||||||
|
uniforms.spectralCentroid = data.spectralCentroid
|
||||||
|
uniforms.rmsLevel = data.rmsLevel
|
||||||
|
|
||||||
|
// Update FFT buffer
|
||||||
|
updateFFTBuffer(data.fftMagnitudes)
|
||||||
|
|
||||||
|
// Update Mel buffer
|
||||||
|
updateMelBuffer(data.melBands)
|
||||||
|
|
||||||
|
// Update sub-bass history buffer
|
||||||
|
updateSubBassHistoryBuffer(data.subBassHistory)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets reactivity value
|
||||||
|
func setReactivity(_ value: Float) {
|
||||||
|
uniforms.reactivity = max(0.0, min(1.0, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
|
||||||
|
private func createBuffers() {
|
||||||
|
// Uniform buffer
|
||||||
|
uniformBuffer = device.makeBuffer(
|
||||||
|
length: MemoryLayout<ShaderUniforms>.stride,
|
||||||
|
options: .storageModeShared
|
||||||
|
)
|
||||||
|
|
||||||
|
// FFT magnitude buffer
|
||||||
|
fftBuffer = device.makeBuffer(
|
||||||
|
length: maxFFTSize * MemoryLayout<Float>.stride,
|
||||||
|
options: .storageModeShared
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mel bands buffer
|
||||||
|
melBuffer = device.makeBuffer(
|
||||||
|
length: melBandCount * MemoryLayout<Float>.stride,
|
||||||
|
options: .storageModeShared
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sub-bass history buffer
|
||||||
|
subBassHistoryBuffer = device.makeBuffer(
|
||||||
|
length: historySize * MemoryLayout<Float>.stride,
|
||||||
|
options: .storageModeShared
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateFFTBuffer(_ magnitudes: [Float]) {
|
||||||
|
guard let buffer = fftBuffer else { return }
|
||||||
|
let count = min(magnitudes.count, maxFFTSize)
|
||||||
|
memcpy(buffer.contents(), magnitudes, count * MemoryLayout<Float>.stride)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateMelBuffer(_ bands: [Float]) {
|
||||||
|
guard let buffer = melBuffer else { return }
|
||||||
|
let count = min(bands.count, melBandCount)
|
||||||
|
memcpy(buffer.contents(), bands, count * MemoryLayout<Float>.stride)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateSubBassHistoryBuffer(_ history: [Float]) {
|
||||||
|
guard let buffer = subBassHistoryBuffer else { return }
|
||||||
|
let count = min(history.count, historySize)
|
||||||
|
memcpy(buffer.contents(), history, count * MemoryLayout<Float>.stride)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadShaders() {
|
||||||
|
guard let library = device.makeDefaultLibrary() else {
|
||||||
|
print("[MetalRenderer] Failed to load shader library")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load vertex shader (shared)
|
||||||
|
guard let vertexFunction = library.makeFunction(name: "vertexShader") else {
|
||||||
|
print("[MetalRenderer] Failed to load vertex shader")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all fragment shaders
|
||||||
|
for mode in VisualizationMode.allCases {
|
||||||
|
guard let fragmentFunction = library.makeFunction(name: mode.shaderFunctionName) else {
|
||||||
|
print("[MetalRenderer] Failed to load shader: \(mode.shaderFunctionName)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let descriptor = MTLRenderPipelineDescriptor()
|
||||||
|
descriptor.vertexFunction = vertexFunction
|
||||||
|
descriptor.fragmentFunction = fragmentFunction
|
||||||
|
descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
|
||||||
|
|
||||||
|
// Enable blending for glow effects
|
||||||
|
descriptor.colorAttachments[0].isBlendingEnabled = true
|
||||||
|
descriptor.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha
|
||||||
|
descriptor.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha
|
||||||
|
descriptor.colorAttachments[0].sourceAlphaBlendFactor = .one
|
||||||
|
descriptor.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha
|
||||||
|
|
||||||
|
do {
|
||||||
|
let pipelineState = try device.makeRenderPipelineState(descriptor: descriptor)
|
||||||
|
pipelineStates[mode] = pipelineState
|
||||||
|
print("[MetalRenderer] Loaded shader: \(mode.displayName)")
|
||||||
|
} catch {
|
||||||
|
print("[MetalRenderer] Failed to create pipeline state for \(mode.displayName): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set initial pipeline state
|
||||||
|
currentPipelineState = pipelineStates[.fftClassic]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MTKViewDelegate
|
||||||
|
|
||||||
|
extension MetalRenderer: MTKViewDelegate {
|
||||||
|
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
|
||||||
|
uniforms.resolution = SIMD2<Float>(Float(size.width), Float(size.height))
|
||||||
|
}
|
||||||
|
|
||||||
|
func draw(in view: MTKView) {
|
||||||
|
guard let pipelineState = currentPipelineState,
|
||||||
|
let drawable = view.currentDrawable,
|
||||||
|
let renderPassDescriptor = view.currentRenderPassDescriptor else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update time
|
||||||
|
uniforms.time = Float(CFAbsoluteTimeGetCurrent() - startTime)
|
||||||
|
|
||||||
|
// Update uniform buffer
|
||||||
|
if let buffer = uniformBuffer {
|
||||||
|
memcpy(buffer.contents(), &uniforms, MemoryLayout<ShaderUniforms>.stride)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create command buffer
|
||||||
|
guard let commandBuffer = commandQueue.makeCommandBuffer(),
|
||||||
|
let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set pipeline state
|
||||||
|
renderEncoder.setRenderPipelineState(pipelineState)
|
||||||
|
|
||||||
|
// Set buffers
|
||||||
|
if let buffer = uniformBuffer {
|
||||||
|
renderEncoder.setFragmentBuffer(buffer, offset: 0, index: 0)
|
||||||
|
}
|
||||||
|
if let buffer = fftBuffer {
|
||||||
|
renderEncoder.setFragmentBuffer(buffer, offset: 0, index: 1)
|
||||||
|
}
|
||||||
|
if let buffer = melBuffer {
|
||||||
|
renderEncoder.setFragmentBuffer(buffer, offset: 0, index: 2)
|
||||||
|
}
|
||||||
|
if let buffer = subBassHistoryBuffer {
|
||||||
|
renderEncoder.setFragmentBuffer(buffer, offset: 0, index: 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw fullscreen quad
|
||||||
|
renderEncoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4)
|
||||||
|
|
||||||
|
renderEncoder.endEncoding()
|
||||||
|
|
||||||
|
commandBuffer.present(drawable)
|
||||||
|
commandBuffer.commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
//
|
||||||
|
// Common.metal
|
||||||
|
// PsytranceVisualizer
|
||||||
|
//
|
||||||
|
// Shared shader functions, types, and psytrance color palette
|
||||||
|
//
|
||||||
|
|
||||||
|
#include <metal_stdlib>
|
||||||
|
using namespace metal;
|
||||||
|
|
||||||
|
// MARK: - Uniforms Structure
|
||||||
|
|
||||||
|
struct ShaderUniforms {
|
||||||
|
float time;
|
||||||
|
float2 resolution;
|
||||||
|
float reactivity;
|
||||||
|
|
||||||
|
float subBassEnergy;
|
||||||
|
float sidechainPump;
|
||||||
|
float sidechainEnvelope;
|
||||||
|
float hnrRatio;
|
||||||
|
float isPeak;
|
||||||
|
float peakIntensity;
|
||||||
|
float spectralCentroid;
|
||||||
|
float rmsLevel;
|
||||||
|
|
||||||
|
int mode;
|
||||||
|
float2 padding;
|
||||||
|
};
|
||||||
|
|
||||||
|
// MARK: - Vertex Data
|
||||||
|
|
||||||
|
struct VertexOut {
|
||||||
|
float4 position [[position]];
|
||||||
|
float2 uv;
|
||||||
|
};
|
||||||
|
|
||||||
|
// MARK: - Psytrance Color Palette
|
||||||
|
|
||||||
|
constant float3 neonMagenta = float3(1.0, 0.0, 1.0);
|
||||||
|
constant float3 neonCyan = float3(0.0, 1.0, 1.0);
|
||||||
|
constant float3 neonGreen = float3(0.224, 1.0, 0.078);
|
||||||
|
constant float3 uvViolet = float3(0.482, 0.0, 1.0);
|
||||||
|
constant float3 hotPink = float3(1.0, 0.2, 0.6);
|
||||||
|
constant float3 electricBlue = float3(0.0, 0.5, 1.0);
|
||||||
|
constant float3 deepPurple = float3(0.1, 0.0, 0.15);
|
||||||
|
|
||||||
|
// MARK: - Palette Functions
|
||||||
|
|
||||||
|
inline float3 getPaletteColor(int index) {
|
||||||
|
switch (index % 6) {
|
||||||
|
case 0: return neonMagenta;
|
||||||
|
case 1: return neonCyan;
|
||||||
|
case 2: return neonGreen;
|
||||||
|
case 3: return uvViolet;
|
||||||
|
case 4: return hotPink;
|
||||||
|
default: return electricBlue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline float3 rainbowPalette(float t) {
|
||||||
|
float3 a = float3(0.5, 0.5, 0.5);
|
||||||
|
float3 b = float3(0.5, 0.5, 0.5);
|
||||||
|
float3 c = float3(1.0, 1.0, 1.0);
|
||||||
|
float3 d = float3(0.0, 0.33, 0.67);
|
||||||
|
return a + b * cos(6.28318 * (c * t + d));
|
||||||
|
}
|
||||||
|
|
||||||
|
inline float3 psytrancePalette(float t, float time) {
|
||||||
|
// Cycle through psytrance colors
|
||||||
|
float phase = fract(t + time * 0.1);
|
||||||
|
|
||||||
|
if (phase < 0.2) {
|
||||||
|
return mix(uvViolet, neonMagenta, phase * 5.0);
|
||||||
|
} else if (phase < 0.4) {
|
||||||
|
return mix(neonMagenta, hotPink, (phase - 0.2) * 5.0);
|
||||||
|
} else if (phase < 0.6) {
|
||||||
|
return mix(hotPink, neonCyan, (phase - 0.4) * 5.0);
|
||||||
|
} else if (phase < 0.8) {
|
||||||
|
return mix(neonCyan, neonGreen, (phase - 0.6) * 5.0);
|
||||||
|
} else {
|
||||||
|
return mix(neonGreen, uvViolet, (phase - 0.8) * 5.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Heatmap for Spectrogram
|
||||||
|
|
||||||
|
inline float3 heatmap(float t) {
|
||||||
|
// Low energy: dark purple
|
||||||
|
// High energy: white through neon colors
|
||||||
|
if (t < 0.2) {
|
||||||
|
return mix(float3(0.05, 0.0, 0.1), uvViolet, t * 5.0);
|
||||||
|
} else if (t < 0.4) {
|
||||||
|
return mix(uvViolet, neonMagenta, (t - 0.2) * 5.0);
|
||||||
|
} else if (t < 0.6) {
|
||||||
|
return mix(neonMagenta, hotPink, (t - 0.4) * 5.0);
|
||||||
|
} else if (t < 0.8) {
|
||||||
|
return mix(hotPink, neonCyan, (t - 0.6) * 5.0);
|
||||||
|
} else {
|
||||||
|
return mix(neonCyan, float3(1.0), (t - 0.8) * 5.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Noise Functions
|
||||||
|
|
||||||
|
// Simplex-like noise
|
||||||
|
inline float hash(float2 p) {
|
||||||
|
float3 p3 = fract(float3(p.xyx) * 0.1031);
|
||||||
|
p3 += dot(p3, p3.yzx + 33.33);
|
||||||
|
return fract((p3.x + p3.y) * p3.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline float noise(float2 p) {
|
||||||
|
float2 i = floor(p);
|
||||||
|
float2 f = fract(p);
|
||||||
|
f = f * f * (3.0 - 2.0 * f);
|
||||||
|
|
||||||
|
float a = hash(i);
|
||||||
|
float b = hash(i + float2(1.0, 0.0));
|
||||||
|
float c = hash(i + float2(0.0, 1.0));
|
||||||
|
float d = hash(i + float2(1.0, 1.0));
|
||||||
|
|
||||||
|
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline float fbm(float2 p, int octaves) {
|
||||||
|
float value = 0.0;
|
||||||
|
float amplitude = 0.5;
|
||||||
|
float frequency = 1.0;
|
||||||
|
|
||||||
|
for (int i = 0; i < octaves; i++) {
|
||||||
|
value += amplitude * noise(p * frequency);
|
||||||
|
frequency *= 2.0;
|
||||||
|
amplitude *= 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3D noise for volumetric effects
|
||||||
|
inline float noise3D(float3 p) {
|
||||||
|
float3 i = floor(p);
|
||||||
|
float3 f = fract(p);
|
||||||
|
f = f * f * (3.0 - 2.0 * f);
|
||||||
|
|
||||||
|
float2 uv = i.xy + float2(37.0, 17.0) * i.z;
|
||||||
|
float a = hash(uv);
|
||||||
|
float b = hash(uv + float2(1.0, 0.0));
|
||||||
|
float c = hash(uv + float2(0.0, 1.0));
|
||||||
|
float d = hash(uv + float2(1.0, 1.0));
|
||||||
|
|
||||||
|
float2 uv2 = uv + float2(37.0, 17.0);
|
||||||
|
float e = hash(uv2);
|
||||||
|
float ff = hash(uv2 + float2(1.0, 0.0));
|
||||||
|
float g = hash(uv2 + float2(0.0, 1.0));
|
||||||
|
float h = hash(uv2 + float2(1.0, 1.0));
|
||||||
|
|
||||||
|
float x1 = mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
|
||||||
|
float x2 = mix(mix(e, ff, f.x), mix(g, h, f.x), f.y);
|
||||||
|
|
||||||
|
return mix(x1, x2, f.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Utility Functions
|
||||||
|
|
||||||
|
inline float2 rotate(float2 p, float angle) {
|
||||||
|
float c = cos(angle);
|
||||||
|
float s = sin(angle);
|
||||||
|
return float2(p.x * c - p.y * s, p.x * s + p.y * c);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline float map(float value, float inMin, float inMax, float outMin, float outMax) {
|
||||||
|
return outMin + (outMax - outMin) * (value - inMin) / (inMax - inMin);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline float smoothstepEdge(float edge0, float edge1, float x) {
|
||||||
|
float t = clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0);
|
||||||
|
return t * t * (3.0 - 2.0 * t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Glow Effect
|
||||||
|
|
||||||
|
inline float3 addGlow(float3 color, float intensity, float3 glowColor) {
|
||||||
|
return color + glowColor * intensity * intensity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SDF Functions for Geometry
|
||||||
|
|
||||||
|
inline float sdCircle(float2 p, float r) {
|
||||||
|
return length(p) - r;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline float sdBox(float2 p, float2 b) {
|
||||||
|
float2 d = abs(p) - b;
|
||||||
|
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline float sdHexagon(float2 p, float r) {
|
||||||
|
const float3 k = float3(-0.866025404, 0.5, 0.577350269);
|
||||||
|
p = abs(p);
|
||||||
|
p -= 2.0 * min(dot(k.xy, p), 0.0) * k.xy;
|
||||||
|
p -= float2(clamp(p.x, -k.z * r, k.z * r), r);
|
||||||
|
return length(p) * sign(p.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline float sdStar(float2 p, float r, int n, float m) {
|
||||||
|
float an = 3.141593 / float(n);
|
||||||
|
float en = 3.141593 / m;
|
||||||
|
float2 acs = float2(cos(an), sin(an));
|
||||||
|
float2 ecs = float2(cos(en), sin(en));
|
||||||
|
|
||||||
|
float bn = fmod(atan2(p.x, p.y), 2.0 * an) - an;
|
||||||
|
p = length(p) * float2(cos(bn), abs(sin(bn)));
|
||||||
|
p -= r * acs;
|
||||||
|
p += ecs * clamp(-dot(p, ecs), 0.0, r * acs.y / ecs.y);
|
||||||
|
return length(p) * sign(p.x);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Vertex Shader (Fullscreen Quad)
|
||||||
|
|
||||||
|
vertex VertexOut vertexShader(uint vertexID [[vertex_id]]) {
|
||||||
|
// Generate fullscreen quad
|
||||||
|
float2 positions[4] = {
|
||||||
|
float2(-1.0, -1.0),
|
||||||
|
float2( 1.0, -1.0),
|
||||||
|
float2(-1.0, 1.0),
|
||||||
|
float2( 1.0, 1.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
float2 uvs[4] = {
|
||||||
|
float2(0.0, 1.0),
|
||||||
|
float2(1.0, 1.0),
|
||||||
|
float2(0.0, 0.0),
|
||||||
|
float2(1.0, 0.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
VertexOut out;
|
||||||
|
out.position = float4(positions[vertexID], 0.0, 1.0);
|
||||||
|
out.uv = uvs[vertexID];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
//
|
||||||
|
// DMTGeometryShader.metal
|
||||||
|
// PsytranceVisualizer
|
||||||
|
//
|
||||||
|
// Sacred geometry patterns: Flower of Life, Metatron's Cube, Sri Yantra, Hexagonal
|
||||||
|
//
|
||||||
|
|
||||||
|
#include <metal_stdlib>
|
||||||
|
using namespace metal;
|
||||||
|
|
||||||
|
#include "Common.metal"
|
||||||
|
|
||||||
|
// === SACRED GEOMETRY PRIMITIVES ===
|
||||||
|
|
||||||
|
// Flower of Life - overlapping circles
|
||||||
|
float flowerOfLife(float2 p, float scale, float time) {
|
||||||
|
p *= scale;
|
||||||
|
|
||||||
|
float result = 0.0;
|
||||||
|
float circleRadius = 0.5;
|
||||||
|
|
||||||
|
// Center circle
|
||||||
|
result = max(result, 1.0 - smoothstep(circleRadius - 0.02, circleRadius, length(p)));
|
||||||
|
|
||||||
|
// 6 circles around center
|
||||||
|
for (int i = 0; i < 6; i++) {
|
||||||
|
float angle = float(i) * 3.14159 / 3.0 + time * 0.1;
|
||||||
|
float2 offset = float2(cos(angle), sin(angle)) * circleRadius;
|
||||||
|
float d = length(p - offset);
|
||||||
|
result = max(result, 1.0 - smoothstep(circleRadius - 0.02, circleRadius, d));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second ring of 12 circles
|
||||||
|
for (int i = 0; i < 12; i++) {
|
||||||
|
float angle = float(i) * 3.14159 / 6.0 + time * 0.05;
|
||||||
|
float2 offset = float2(cos(angle), sin(angle)) * circleRadius * 2.0;
|
||||||
|
float d = length(p - offset);
|
||||||
|
result = max(result, 0.5 * (1.0 - smoothstep(circleRadius - 0.02, circleRadius, d)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metatron's Cube - 13 circles with connecting lines
|
||||||
|
float metatronsCube(float2 p, float scale, float time) {
|
||||||
|
p *= scale;
|
||||||
|
|
||||||
|
float result = 0.0;
|
||||||
|
float nodeRadius = 0.08;
|
||||||
|
float lineWidth = 0.01;
|
||||||
|
|
||||||
|
// Define the 13 points of Metatron's Cube
|
||||||
|
float2 points[13];
|
||||||
|
points[0] = float2(0.0, 0.0); // Center
|
||||||
|
|
||||||
|
// Inner hexagon
|
||||||
|
for (int i = 0; i < 6; i++) {
|
||||||
|
float angle = float(i) * 3.14159 / 3.0 + time * 0.1;
|
||||||
|
points[i + 1] = float2(cos(angle), sin(angle)) * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outer hexagon (rotated)
|
||||||
|
for (int i = 0; i < 6; i++) {
|
||||||
|
float angle = float(i) * 3.14159 / 3.0 + 3.14159 / 6.0 + time * 0.1;
|
||||||
|
points[i + 7] = float2(cos(angle), sin(angle)) * 0.866;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw nodes
|
||||||
|
for (int i = 0; i < 13; i++) {
|
||||||
|
float d = length(p - points[i]);
|
||||||
|
float node = 1.0 - smoothstep(nodeRadius - 0.01, nodeRadius, d);
|
||||||
|
result = max(result, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw connecting lines
|
||||||
|
for (int i = 0; i < 13; i++) {
|
||||||
|
for (int j = i + 1; j < 13; j++) {
|
||||||
|
float2 a = points[i];
|
||||||
|
float2 b = points[j];
|
||||||
|
float2 pa = p - a;
|
||||||
|
float2 ba = b - a;
|
||||||
|
float t = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
|
||||||
|
float d = length(pa - ba * t);
|
||||||
|
float line = 1.0 - smoothstep(lineWidth, lineWidth + 0.005, d);
|
||||||
|
result = max(result, line * 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sri Yantra - 9 interlocking triangles
|
||||||
|
float sriYantra(float2 p, float scale, float time) {
|
||||||
|
p *= scale;
|
||||||
|
|
||||||
|
float result = 0.0;
|
||||||
|
float lineWidth = 0.015;
|
||||||
|
|
||||||
|
// Rotating factor
|
||||||
|
float rot = time * 0.05;
|
||||||
|
|
||||||
|
// Draw 4 upward triangles
|
||||||
|
for (int i = 0; i < 4; i++) {
|
||||||
|
float size = 0.3 + float(i) * 0.15;
|
||||||
|
float yOffset = -0.1 + float(i) * 0.05;
|
||||||
|
|
||||||
|
float2 tp = p - float2(0.0, yOffset);
|
||||||
|
tp = rotate(tp, rot);
|
||||||
|
|
||||||
|
// Triangle SDF
|
||||||
|
float2 a = float2(0.0, size);
|
||||||
|
float2 b = float2(-size * 0.866, -size * 0.5);
|
||||||
|
float2 c = float2(size * 0.866, -size * 0.5);
|
||||||
|
|
||||||
|
float d1 = dot(tp - a, normalize(float2(b.y - a.y, a.x - b.x)));
|
||||||
|
float d2 = dot(tp - b, normalize(float2(c.y - b.y, b.x - c.x)));
|
||||||
|
float d3 = dot(tp - c, normalize(float2(a.y - c.y, c.x - a.x)));
|
||||||
|
|
||||||
|
float triangleDist = max(max(d1, d2), d3);
|
||||||
|
float edge = 1.0 - smoothstep(0.0, lineWidth, abs(triangleDist));
|
||||||
|
result = max(result, edge * (1.0 - float(i) * 0.15));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw 5 downward triangles
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
float size = 0.25 + float(i) * 0.12;
|
||||||
|
float yOffset = 0.1 - float(i) * 0.04;
|
||||||
|
|
||||||
|
float2 tp = p - float2(0.0, yOffset);
|
||||||
|
tp = rotate(tp, -rot);
|
||||||
|
|
||||||
|
float2 a = float2(0.0, -size);
|
||||||
|
float2 b = float2(-size * 0.866, size * 0.5);
|
||||||
|
float2 c = float2(size * 0.866, size * 0.5);
|
||||||
|
|
||||||
|
float d1 = dot(tp - a, normalize(float2(b.y - a.y, a.x - b.x)));
|
||||||
|
float d2 = dot(tp - b, normalize(float2(c.y - b.y, b.x - c.x)));
|
||||||
|
float d3 = dot(tp - c, normalize(float2(a.y - c.y, c.x - a.x)));
|
||||||
|
|
||||||
|
float triangleDist = max(max(d1, d2), d3);
|
||||||
|
float edge = 1.0 - smoothstep(0.0, lineWidth, abs(triangleDist));
|
||||||
|
result = max(result, edge * (1.0 - float(i) * 0.12));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Central bindu (point)
|
||||||
|
float bindu = 1.0 - smoothstep(0.03, 0.04, length(p));
|
||||||
|
result = max(result, bindu);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hexagonal grid pattern
|
||||||
|
float hexagonalPattern(float2 p, float scale, float time) {
|
||||||
|
p *= scale;
|
||||||
|
|
||||||
|
// Hexagonal grid transformation
|
||||||
|
float2 s = float2(1.0, 1.732);
|
||||||
|
float2 h = s * 0.5;
|
||||||
|
|
||||||
|
float2 a = fmod(p, s) - h;
|
||||||
|
float2 b = fmod(p + h, s) - h;
|
||||||
|
|
||||||
|
float2 gv = dot(a, a) < dot(b, b) ? a : b;
|
||||||
|
|
||||||
|
float hexDist = max(abs(gv.x), dot(abs(gv), normalize(float2(1.0, 1.732))));
|
||||||
|
|
||||||
|
float edge = 1.0 - smoothstep(0.4, 0.42, hexDist);
|
||||||
|
float fill = smoothstep(0.38, 0.4, hexDist);
|
||||||
|
|
||||||
|
// Animate individual hexagons
|
||||||
|
float2 cellId = floor(p / s);
|
||||||
|
float cellPhase = hash(cellId + floor(time * 0.5)) * 2.0 * 3.14159;
|
||||||
|
float pulse = 0.5 + 0.5 * sin(time * 3.0 + cellPhase);
|
||||||
|
|
||||||
|
return edge + fill * pulse * 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === MAIN FRAGMENT SHADER ===
|
||||||
|
|
||||||
|
fragment float4 dmtGeometryFragment(
|
||||||
|
VertexOut in [[stage_in]],
|
||||||
|
constant ShaderUniforms& uniforms [[buffer(0)]],
|
||||||
|
constant float* fftData [[buffer(1)]],
|
||||||
|
constant float* melData [[buffer(2)]],
|
||||||
|
constant float* historyData [[buffer(3)]]
|
||||||
|
) {
|
||||||
|
float2 uv = in.uv;
|
||||||
|
float2 resolution = uniforms.resolution;
|
||||||
|
float time = uniforms.time;
|
||||||
|
float reactivity = uniforms.reactivity;
|
||||||
|
|
||||||
|
float subBass = uniforms.subBassEnergy;
|
||||||
|
float hnr = uniforms.hnrRatio;
|
||||||
|
float peak = uniforms.isPeak;
|
||||||
|
float peakIntensity = uniforms.peakIntensity;
|
||||||
|
|
||||||
|
// Aspect ratio correction
|
||||||
|
float aspectRatio = resolution.x / resolution.y;
|
||||||
|
float2 p = (uv - 0.5) * 2.0;
|
||||||
|
p.x *= aspectRatio;
|
||||||
|
|
||||||
|
// Scale pulsing with sub-bass
|
||||||
|
float scale = 2.0 + subBass * 0.5 * (0.5 + reactivity * 0.5);
|
||||||
|
p *= scale;
|
||||||
|
|
||||||
|
// Rotation
|
||||||
|
float rotation = time * 0.1;
|
||||||
|
p = rotate(p, rotation);
|
||||||
|
|
||||||
|
// Determine which geometry to show
|
||||||
|
// Changes on peaks or every few seconds
|
||||||
|
float cycleTime = 8.0; // Seconds per geometry
|
||||||
|
float cyclePhase = fmod(time, cycleTime * 4.0) / cycleTime;
|
||||||
|
int geometryIndex = int(cyclePhase);
|
||||||
|
|
||||||
|
// Force change on strong peaks
|
||||||
|
if (peak > 0.5 && peakIntensity > 0.7) {
|
||||||
|
geometryIndex = int(fmod(float(geometryIndex) + 1.0, 4.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate all geometries (for blending)
|
||||||
|
float flower = flowerOfLife(p, 1.0, time);
|
||||||
|
float metatron = metatronsCube(p, 1.5, time);
|
||||||
|
float yantra = sriYantra(p, 1.2, time);
|
||||||
|
float hexGrid = hexagonalPattern(p, 3.0, time);
|
||||||
|
|
||||||
|
// Select primary and secondary for blending
|
||||||
|
float primary = 0.0;
|
||||||
|
float secondary = 0.0;
|
||||||
|
float blendPhase = fract(cyclePhase);
|
||||||
|
|
||||||
|
switch (geometryIndex) {
|
||||||
|
case 0:
|
||||||
|
primary = flower;
|
||||||
|
secondary = metatron;
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
primary = metatron;
|
||||||
|
secondary = yantra;
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
primary = yantra;
|
||||||
|
secondary = hexGrid;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
primary = hexGrid;
|
||||||
|
secondary = flower;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smooth transition
|
||||||
|
float transitionWindow = 0.2; // 20% of cycle for transition
|
||||||
|
float blend = smoothstep(1.0 - transitionWindow, 1.0, blendPhase);
|
||||||
|
float geometry = mix(primary, secondary, blend);
|
||||||
|
|
||||||
|
// Complexity based on HNR (more harmonic = more detail)
|
||||||
|
geometry *= 0.7 + hnr * 0.3;
|
||||||
|
|
||||||
|
// Color based on geometry and audio
|
||||||
|
float colorPhase = time * 0.1 + geometry * 0.5;
|
||||||
|
float3 geometryColor = psytrancePalette(colorPhase, time);
|
||||||
|
|
||||||
|
// Glow intensity from peak
|
||||||
|
float glowIntensity = 0.5 + peakIntensity * 0.5;
|
||||||
|
float3 glowColor = mix(neonMagenta, neonCyan, 0.5 + 0.5 * sin(time));
|
||||||
|
|
||||||
|
// Compose final color
|
||||||
|
float3 finalColor = geometryColor * geometry;
|
||||||
|
|
||||||
|
// Add glow
|
||||||
|
finalColor = addGlow(finalColor, geometry * glowIntensity, glowColor);
|
||||||
|
|
||||||
|
// Background - subtle pulsing gradient
|
||||||
|
float dist = length(uv - 0.5);
|
||||||
|
float3 bgColor = mix(deepPurple, uvViolet * 0.3, dist);
|
||||||
|
bgColor *= 0.8 + 0.2 * subBass;
|
||||||
|
|
||||||
|
finalColor = mix(bgColor, finalColor, clamp(geometry * 1.5, 0.0, 1.0));
|
||||||
|
|
||||||
|
// Peak flash
|
||||||
|
if (peak > 0.5) {
|
||||||
|
finalColor += float3(1.0) * peakIntensity * 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outer glow
|
||||||
|
float outerGlow = exp(-dist * 3.0);
|
||||||
|
finalColor += neonMagenta * outerGlow * 0.1 * subBass;
|
||||||
|
|
||||||
|
return float4(finalColor, 1.0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
//
|
||||||
|
// FFTClassicShader.metal
|
||||||
|
// PsytranceVisualizer
|
||||||
|
//
|
||||||
|
// Classic FFT bar visualization with glow effects
|
||||||
|
//
|
||||||
|
|
||||||
|
#include <metal_stdlib>
|
||||||
|
using namespace metal;
|
||||||
|
|
||||||
|
// Include common definitions
|
||||||
|
#include "Common.metal"
|
||||||
|
|
||||||
|
fragment float4 fftClassicFragment(
|
||||||
|
VertexOut in [[stage_in]],
|
||||||
|
constant ShaderUniforms& uniforms [[buffer(0)]],
|
||||||
|
constant float* fftData [[buffer(1)]]
|
||||||
|
) {
|
||||||
|
float2 uv = in.uv;
|
||||||
|
float2 resolution = uniforms.resolution;
|
||||||
|
float time = uniforms.time;
|
||||||
|
float reactivity = uniforms.reactivity;
|
||||||
|
|
||||||
|
// Number of bars to display
|
||||||
|
const int numBars = 64;
|
||||||
|
const float barWidth = 1.0 / float(numBars);
|
||||||
|
const float barGap = barWidth * 0.2;
|
||||||
|
const float actualBarWidth = barWidth - barGap;
|
||||||
|
|
||||||
|
// Determine which bar this pixel belongs to
|
||||||
|
int barIndex = int(uv.x * float(numBars));
|
||||||
|
barIndex = clamp(barIndex, 0, numBars - 1);
|
||||||
|
|
||||||
|
// Get FFT magnitude for this bar (with some averaging for smoothness)
|
||||||
|
float magnitude = fftData[barIndex];
|
||||||
|
|
||||||
|
// Apply reactivity scaling
|
||||||
|
magnitude = magnitude * (0.5 + reactivity * 1.5);
|
||||||
|
magnitude = clamp(magnitude, 0.0, 1.0);
|
||||||
|
|
||||||
|
// Calculate bar position within its cell
|
||||||
|
float barCellX = fract(uv.x * float(numBars));
|
||||||
|
float barCenterX = 0.5;
|
||||||
|
|
||||||
|
// Distance from bar center (for width calculation)
|
||||||
|
float distFromCenter = abs(barCellX - barCenterX);
|
||||||
|
float halfWidth = actualBarWidth * 0.5 / barWidth;
|
||||||
|
|
||||||
|
// Check if we're inside the bar horizontally
|
||||||
|
bool insideBarX = distFromCenter < halfWidth;
|
||||||
|
|
||||||
|
// Bar height from bottom
|
||||||
|
float barHeight = magnitude;
|
||||||
|
|
||||||
|
// Add some bounce on peaks
|
||||||
|
if (uniforms.isPeak > 0.5) {
|
||||||
|
barHeight += uniforms.peakIntensity * 0.1 * sin(time * 20.0 + float(barIndex) * 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're inside the bar vertically (from bottom)
|
||||||
|
float yFromBottom = 1.0 - uv.y;
|
||||||
|
bool insideBarY = yFromBottom < barHeight;
|
||||||
|
|
||||||
|
// Color based on frequency and magnitude
|
||||||
|
float colorPhase = float(barIndex) / float(numBars) + time * 0.05;
|
||||||
|
float3 barColor = psytrancePalette(colorPhase, time);
|
||||||
|
|
||||||
|
// Intensity gradient from bottom to top
|
||||||
|
float intensityGradient = yFromBottom / max(barHeight, 0.01);
|
||||||
|
intensityGradient = clamp(intensityGradient, 0.0, 1.0);
|
||||||
|
|
||||||
|
// Make top of bars brighter
|
||||||
|
barColor = mix(barColor * 0.6, barColor * 1.5, intensityGradient);
|
||||||
|
|
||||||
|
// Calculate glow
|
||||||
|
float glowRadius = 0.05 * (1.0 + magnitude);
|
||||||
|
float distToBar = 0.0;
|
||||||
|
|
||||||
|
if (!insideBarX) {
|
||||||
|
distToBar = (distFromCenter - halfWidth) * barWidth;
|
||||||
|
}
|
||||||
|
if (!insideBarY && yFromBottom >= barHeight) {
|
||||||
|
float vertDist = yFromBottom - barHeight;
|
||||||
|
distToBar = max(distToBar, vertDist);
|
||||||
|
}
|
||||||
|
|
||||||
|
float glow = exp(-distToBar * distToBar / (glowRadius * glowRadius * 2.0));
|
||||||
|
glow *= magnitude;
|
||||||
|
|
||||||
|
// Final color
|
||||||
|
float3 finalColor = float3(0.0);
|
||||||
|
|
||||||
|
if (insideBarX && insideBarY) {
|
||||||
|
// Inside the bar
|
||||||
|
finalColor = barColor;
|
||||||
|
|
||||||
|
// Add peak cap (bright line at top)
|
||||||
|
float capThickness = 0.01;
|
||||||
|
if (abs(yFromBottom - barHeight) < capThickness) {
|
||||||
|
finalColor = float3(1.0); // White cap
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Add glow outside bars
|
||||||
|
finalColor = barColor * glow * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add subtle background pulse with sub-bass
|
||||||
|
float bgPulse = uniforms.subBassEnergy * 0.05;
|
||||||
|
finalColor += deepPurple * bgPulse;
|
||||||
|
|
||||||
|
// Add overall glow at peaks
|
||||||
|
if (uniforms.isPeak > 0.5) {
|
||||||
|
finalColor += neonMagenta * uniforms.peakIntensity * 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return float4(finalColor, 1.0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
//
|
||||||
|
// HNRShader.metal
|
||||||
|
// PsytranceVisualizer
|
||||||
|
//
|
||||||
|
// Harmonic-to-Noise ratio visualization with geometric shapes vs chaos
|
||||||
|
//
|
||||||
|
|
||||||
|
#include <metal_stdlib>
|
||||||
|
using namespace metal;
|
||||||
|
|
||||||
|
#include "Common.metal"
|
||||||
|
|
||||||
|
fragment float4 hnrFragment(
|
||||||
|
VertexOut in [[stage_in]],
|
||||||
|
constant ShaderUniforms& uniforms [[buffer(0)]],
|
||||||
|
constant float* fftData [[buffer(1)]],
|
||||||
|
constant float* melData [[buffer(2)]],
|
||||||
|
constant float* historyData [[buffer(3)]]
|
||||||
|
) {
|
||||||
|
float2 uv = in.uv;
|
||||||
|
float2 resolution = uniforms.resolution;
|
||||||
|
float time = uniforms.time;
|
||||||
|
float reactivity = uniforms.reactivity;
|
||||||
|
float hnr = uniforms.hnrRatio;
|
||||||
|
float subBass = uniforms.subBassEnergy;
|
||||||
|
|
||||||
|
// Center coordinates
|
||||||
|
float2 center = float2(0.5, 0.5);
|
||||||
|
float aspectRatio = resolution.x / resolution.y;
|
||||||
|
|
||||||
|
float2 p = uv - center;
|
||||||
|
p.x *= aspectRatio;
|
||||||
|
|
||||||
|
float dist = length(p);
|
||||||
|
float angle = atan2(p.y, p.x);
|
||||||
|
|
||||||
|
// === HARMONIC SIDE (High HNR = Clear geometric shapes) ===
|
||||||
|
|
||||||
|
// Rotating hexagon
|
||||||
|
float2 rotP = rotate(p, time * 0.5);
|
||||||
|
float hexDist = sdHexagon(rotP, 0.2 + subBass * 0.1);
|
||||||
|
float hexEdge = 1.0 - smoothstep(0.0, 0.02, abs(hexDist));
|
||||||
|
|
||||||
|
// Inner rotating triangle (star)
|
||||||
|
float2 rotP2 = rotate(p, -time * 0.3);
|
||||||
|
float starDist = sdStar(rotP2, 0.12 + subBass * 0.05, 3, 2.5);
|
||||||
|
float starEdge = 1.0 - smoothstep(0.0, 0.015, abs(starDist));
|
||||||
|
|
||||||
|
// Concentric circles
|
||||||
|
float circles = 0.0;
|
||||||
|
for (int i = 0; i < 4; i++) {
|
||||||
|
float radius = 0.1 + float(i) * 0.08 + sin(time + float(i)) * 0.02;
|
||||||
|
float circleDist = abs(dist - radius);
|
||||||
|
float circle = 1.0 - smoothstep(0.0, 0.008, circleDist);
|
||||||
|
circles += circle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine harmonic shapes
|
||||||
|
float harmonicShapes = hexEdge + starEdge * 0.8 + circles * 0.5;
|
||||||
|
harmonicShapes = clamp(harmonicShapes, 0.0, 1.0);
|
||||||
|
|
||||||
|
// Harmonic color - clean neon
|
||||||
|
float3 harmonicColor = mix(neonCyan, neonMagenta, 0.5 + 0.5 * sin(angle * 2.0 + time));
|
||||||
|
|
||||||
|
// === NOISE SIDE (Low HNR = Chaotic particles) ===
|
||||||
|
|
||||||
|
// Noise-based particles
|
||||||
|
float noiseField = 0.0;
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
float2 noiseP = p * (3.0 + float(i) * 2.0);
|
||||||
|
noiseP += time * float(i + 1) * 0.1;
|
||||||
|
float n = noise(noiseP);
|
||||||
|
n = pow(n, 2.0);
|
||||||
|
noiseField += n * (1.0 / float(i + 1));
|
||||||
|
}
|
||||||
|
noiseField = clamp(noiseField, 0.0, 1.0);
|
||||||
|
|
||||||
|
// Turbulent swirls
|
||||||
|
float2 turbP = p * 4.0;
|
||||||
|
float turbulence = fbm(turbP + time * 0.5, 4);
|
||||||
|
|
||||||
|
// Chaotic speckles
|
||||||
|
float speckles = 0.0;
|
||||||
|
for (int i = 0; i < 30; i++) {
|
||||||
|
float2 specklePos = float2(
|
||||||
|
hash(float2(float(i) * 0.1, time * 0.01)) - 0.5,
|
||||||
|
hash(float2(float(i) * 0.2, time * 0.01 + 0.5)) - 0.5
|
||||||
|
);
|
||||||
|
specklePos *= 0.8;
|
||||||
|
specklePos.x *= aspectRatio;
|
||||||
|
|
||||||
|
float speckleDist = length(p - specklePos);
|
||||||
|
float speckle = exp(-speckleDist * speckleDist * 500.0);
|
||||||
|
speckle *= hash(float2(float(i), floor(time * 2.0)));
|
||||||
|
speckles += speckle;
|
||||||
|
}
|
||||||
|
|
||||||
|
float noiseVisual = noiseField * 0.4 + turbulence * 0.3 + speckles * 0.3;
|
||||||
|
noiseVisual = clamp(noiseVisual, 0.0, 1.0);
|
||||||
|
|
||||||
|
// Noise color - harsh, flickering
|
||||||
|
float3 noiseColor = mix(hotPink, uvViolet, turbulence);
|
||||||
|
noiseColor *= 0.8 + 0.2 * sin(time * 20.0 + noise(p * 10.0) * 10.0);
|
||||||
|
|
||||||
|
// === BLEND based on HNR ===
|
||||||
|
|
||||||
|
// HNR determines the mix: 1.0 = pure harmonic, 0.0 = pure noise
|
||||||
|
float harmonicAmount = hnr;
|
||||||
|
float noiseAmount = 1.0 - hnr;
|
||||||
|
|
||||||
|
// Apply reactivity to make transition more dramatic
|
||||||
|
harmonicAmount = pow(harmonicAmount, 1.0 / (1.0 + reactivity));
|
||||||
|
|
||||||
|
float3 harmonicContrib = harmonicColor * harmonicShapes * harmonicAmount;
|
||||||
|
float3 noiseContrib = noiseColor * noiseVisual * noiseAmount;
|
||||||
|
|
||||||
|
float3 finalColor = harmonicContrib + noiseContrib;
|
||||||
|
|
||||||
|
// Add center indicator showing current HNR
|
||||||
|
float indicator = smoothstep(0.25, 0.24, dist) - smoothstep(0.24, 0.23, dist);
|
||||||
|
float indicatorFill = smoothstep(0.23, 0.22, dist);
|
||||||
|
|
||||||
|
// Split indicator by HNR
|
||||||
|
float harmonicSide = step(0.0, p.x);
|
||||||
|
float noiseSide = 1.0 - harmonicSide;
|
||||||
|
|
||||||
|
finalColor += neonCyan * indicator * 0.3;
|
||||||
|
finalColor += neonCyan * indicatorFill * harmonicSide * hnr * 0.2;
|
||||||
|
finalColor += hotPink * indicatorFill * noiseSide * (1.0 - hnr) * 0.2;
|
||||||
|
|
||||||
|
// Background glow
|
||||||
|
float bgGlow = exp(-dist * dist * 4.0);
|
||||||
|
float3 bgColor = mix(deepPurple, uvViolet * 0.3, dist);
|
||||||
|
finalColor += bgColor * (1.0 - clamp(harmonicShapes + noiseVisual, 0.0, 1.0));
|
||||||
|
|
||||||
|
// Peak flash
|
||||||
|
if (uniforms.isPeak > 0.5) {
|
||||||
|
finalColor += float3(1.0) * uniforms.peakIntensity * 0.15 * exp(-dist * 3.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return float4(finalColor, 1.0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
//
|
||||||
|
// MandelbrotShader.metal
|
||||||
|
// PsytranceVisualizer
|
||||||
|
//
|
||||||
|
// Audio-reactive Mandelbrot fractal with zoom and color cycling
|
||||||
|
//
|
||||||
|
|
||||||
|
#include <metal_stdlib>
|
||||||
|
using namespace metal;
|
||||||
|
|
||||||
|
#include "Common.metal"
|
||||||
|
|
||||||
|
fragment float4 mandelbrotFragment(
|
||||||
|
VertexOut in [[stage_in]],
|
||||||
|
constant ShaderUniforms& uniforms [[buffer(0)]],
|
||||||
|
constant float* fftData [[buffer(1)]],
|
||||||
|
constant float* melData [[buffer(2)]],
|
||||||
|
constant float* historyData [[buffer(3)]]
|
||||||
|
) {
|
||||||
|
float2 uv = in.uv;
|
||||||
|
float2 resolution = uniforms.resolution;
|
||||||
|
float time = uniforms.time;
|
||||||
|
float reactivity = uniforms.reactivity;
|
||||||
|
|
||||||
|
float subBass = uniforms.subBassEnergy;
|
||||||
|
float pump = uniforms.sidechainPump;
|
||||||
|
float centroid = uniforms.spectralCentroid;
|
||||||
|
|
||||||
|
// Aspect ratio correction
|
||||||
|
float aspectRatio = resolution.x / resolution.y;
|
||||||
|
|
||||||
|
// Map UV to complex plane
|
||||||
|
float2 c = (uv - 0.5) * 2.0;
|
||||||
|
c.x *= aspectRatio;
|
||||||
|
|
||||||
|
// Audio-reactive zoom level
|
||||||
|
// Base zoom increases over time, modulated by sub-bass
|
||||||
|
float baseZoom = 1.0 + time * 0.02;
|
||||||
|
float audioZoom = subBass * 0.5 * (0.5 + reactivity * 0.5);
|
||||||
|
float zoom = pow(2.0, baseZoom + audioZoom);
|
||||||
|
|
||||||
|
// Zoom center - drifts based on sidechain
|
||||||
|
float2 zoomCenter = float2(-0.7, 0.0);
|
||||||
|
zoomCenter.x += sin(time * 0.1) * 0.3 + pump * 0.1 * sin(time);
|
||||||
|
zoomCenter.y += cos(time * 0.13) * 0.2 + pump * 0.1 * cos(time);
|
||||||
|
|
||||||
|
// Apply zoom
|
||||||
|
c = c / zoom + zoomCenter;
|
||||||
|
|
||||||
|
// Mandelbrot iteration
|
||||||
|
float2 z = float2(0.0);
|
||||||
|
int maxIterations = int(50.0 + reactivity * 100.0);
|
||||||
|
int iterations = 0;
|
||||||
|
|
||||||
|
float smoothIter = 0.0;
|
||||||
|
|
||||||
|
for (int i = 0; i < 150; i++) {
|
||||||
|
if (i >= maxIterations) break;
|
||||||
|
|
||||||
|
// z = z^2 + c
|
||||||
|
float2 zNew = float2(
|
||||||
|
z.x * z.x - z.y * z.y + c.x,
|
||||||
|
2.0 * z.x * z.y + c.y
|
||||||
|
);
|
||||||
|
z = zNew;
|
||||||
|
|
||||||
|
float mag2 = dot(z, z);
|
||||||
|
if (mag2 > 256.0) {
|
||||||
|
// Smooth iteration count
|
||||||
|
smoothIter = float(i) - log2(log2(mag2)) + 4.0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
iterations = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize iteration count
|
||||||
|
float normalizedIter = smoothIter / float(maxIterations);
|
||||||
|
|
||||||
|
// Color based on iterations
|
||||||
|
float3 color;
|
||||||
|
|
||||||
|
if (iterations >= maxIterations - 1) {
|
||||||
|
// Inside the set - deep color
|
||||||
|
color = deepPurple * (0.5 + 0.5 * subBass);
|
||||||
|
} else {
|
||||||
|
// Outside - color cycling based on iterations and audio
|
||||||
|
float colorPhase = normalizedIter + time * 0.1 + centroid;
|
||||||
|
|
||||||
|
// Use psytrance palette with color rotation
|
||||||
|
color = psytrancePalette(colorPhase, time);
|
||||||
|
|
||||||
|
// Modulate brightness by iteration depth
|
||||||
|
float brightness = 0.5 + 0.5 * sin(smoothIter * 0.3);
|
||||||
|
color *= brightness;
|
||||||
|
|
||||||
|
// Add glow at boundary
|
||||||
|
float edgeFactor = 1.0 - normalizedIter;
|
||||||
|
edgeFactor = pow(edgeFactor, 3.0);
|
||||||
|
color = addGlow(color, edgeFactor * 0.5, neonCyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sub-bass pulse effect
|
||||||
|
color *= 0.8 + 0.2 * subBass;
|
||||||
|
|
||||||
|
// Sidechain breathing
|
||||||
|
float breathe = 1.0 + pump * 0.1;
|
||||||
|
color *= breathe;
|
||||||
|
|
||||||
|
// Peak flash in bright areas
|
||||||
|
if (uniforms.isPeak > 0.5 && iterations < maxIterations - 1) {
|
||||||
|
color += neonMagenta * uniforms.peakIntensity * 0.2 * normalizedIter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtle vignette
|
||||||
|
float2 vignetteuv = uv - 0.5;
|
||||||
|
float vignette = 1.0 - dot(vignetteuv, vignetteuv) * 0.5;
|
||||||
|
color *= vignette;
|
||||||
|
|
||||||
|
return float4(color, 1.0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
//
|
||||||
|
// MelSpectrogramShader.metal
|
||||||
|
// PsytranceVisualizer
|
||||||
|
//
|
||||||
|
// Mel spectrogram with scrolling waterfall display
|
||||||
|
//
|
||||||
|
|
||||||
|
#include <metal_stdlib>
|
||||||
|
using namespace metal;
|
||||||
|
|
||||||
|
#include "Common.metal"
|
||||||
|
|
||||||
|
fragment float4 melSpectrogramFragment(
|
||||||
|
VertexOut in [[stage_in]],
|
||||||
|
constant ShaderUniforms& uniforms [[buffer(0)]],
|
||||||
|
constant float* fftData [[buffer(1)]],
|
||||||
|
constant float* melData [[buffer(2)]],
|
||||||
|
constant float* historyData [[buffer(3)]]
|
||||||
|
) {
|
||||||
|
float2 uv = in.uv;
|
||||||
|
float time = uniforms.time;
|
||||||
|
float reactivity = uniforms.reactivity;
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
const int numBands = 64;
|
||||||
|
const int historyLength = 128;
|
||||||
|
|
||||||
|
// Map UV to mel band and history position
|
||||||
|
int bandIndex = int(uv.x * float(numBands));
|
||||||
|
bandIndex = clamp(bandIndex, 0, numBands - 1);
|
||||||
|
|
||||||
|
// Scrolling effect - newer data at bottom
|
||||||
|
float scrollOffset = fract(time * 0.5); // Scroll speed
|
||||||
|
float yPos = fract(uv.y + scrollOffset);
|
||||||
|
|
||||||
|
// Get mel magnitude
|
||||||
|
float magnitude = melData[bandIndex];
|
||||||
|
magnitude = magnitude * (0.5 + reactivity * 1.5);
|
||||||
|
magnitude = clamp(magnitude, 0.0, 1.0);
|
||||||
|
|
||||||
|
// Create waterfall effect using history
|
||||||
|
int historyIndex = int(yPos * float(historyLength));
|
||||||
|
historyIndex = clamp(historyIndex, 0, historyLength - 1);
|
||||||
|
|
||||||
|
// Combine current and historical data for waterfall
|
||||||
|
float historicalValue = historyData[historyIndex];
|
||||||
|
|
||||||
|
// Blend between current magnitude and position-based intensity
|
||||||
|
float intensity = magnitude;
|
||||||
|
|
||||||
|
// Add some variance based on band position
|
||||||
|
float bandPhase = float(bandIndex) / float(numBands);
|
||||||
|
intensity *= 0.8 + 0.2 * sin(bandPhase * 6.28318 + time);
|
||||||
|
|
||||||
|
// Apply fade for older data (top of screen)
|
||||||
|
float ageFade = 1.0 - uv.y * 0.3;
|
||||||
|
intensity *= ageFade;
|
||||||
|
|
||||||
|
// Generate color using heatmap
|
||||||
|
float3 color = heatmap(intensity);
|
||||||
|
|
||||||
|
// Add frequency-dependent hue shift
|
||||||
|
float hueShift = bandPhase * 0.3;
|
||||||
|
color = psytrancePalette(intensity + hueShift, time);
|
||||||
|
|
||||||
|
// Modulate by actual intensity
|
||||||
|
color *= 0.3 + intensity * 0.7;
|
||||||
|
|
||||||
|
// Add grid lines for visual reference
|
||||||
|
float gridX = abs(fract(uv.x * float(numBands)) - 0.5) * 2.0;
|
||||||
|
float gridY = abs(fract(uv.y * 16.0) - 0.5) * 2.0;
|
||||||
|
|
||||||
|
float gridLine = smoothstep(0.95, 1.0, gridX) + smoothstep(0.95, 1.0, gridY);
|
||||||
|
gridLine *= 0.1;
|
||||||
|
|
||||||
|
color += float3(gridLine) * uvViolet;
|
||||||
|
|
||||||
|
// Add glow on high energy
|
||||||
|
if (intensity > 0.7) {
|
||||||
|
float glow = (intensity - 0.7) / 0.3;
|
||||||
|
color = addGlow(color, glow * 0.5, neonCyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peak flash
|
||||||
|
if (uniforms.isPeak > 0.5) {
|
||||||
|
color += neonMagenta * uniforms.peakIntensity * 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sub-bass emphasis on lower bands
|
||||||
|
if (bandIndex < 8) {
|
||||||
|
color += uvViolet * uniforms.subBassEnergy * 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
return float4(color, 1.0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
//
|
||||||
|
// SidechainPumpShader.metal
|
||||||
|
// PsytranceVisualizer
|
||||||
|
//
|
||||||
|
// Visualizes sidechain pumping with breathing zoom effect
|
||||||
|
//
|
||||||
|
|
||||||
|
#include <metal_stdlib>
|
||||||
|
using namespace metal;
|
||||||
|
|
||||||
|
#include "Common.metal"
|
||||||
|
|
||||||
|
fragment float4 sidechainPumpFragment(
|
||||||
|
VertexOut in [[stage_in]],
|
||||||
|
constant ShaderUniforms& uniforms [[buffer(0)]],
|
||||||
|
constant float* fftData [[buffer(1)]],
|
||||||
|
constant float* melData [[buffer(2)]],
|
||||||
|
constant float* historyData [[buffer(3)]]
|
||||||
|
) {
|
||||||
|
float2 uv = in.uv;
|
||||||
|
float2 resolution = uniforms.resolution;
|
||||||
|
float time = uniforms.time;
|
||||||
|
float reactivity = uniforms.reactivity;
|
||||||
|
|
||||||
|
float pump = uniforms.sidechainPump;
|
||||||
|
float envelope = uniforms.sidechainEnvelope;
|
||||||
|
float subBass = uniforms.subBassEnergy;
|
||||||
|
|
||||||
|
// Center and aspect ratio correction
|
||||||
|
float2 center = float2(0.5, 0.5);
|
||||||
|
float aspectRatio = resolution.x / resolution.y;
|
||||||
|
|
||||||
|
float2 p = uv - center;
|
||||||
|
p.x *= aspectRatio;
|
||||||
|
|
||||||
|
// Apply breathing zoom effect
|
||||||
|
float zoomAmount = 1.0 + pump * 0.3 * (0.5 + reactivity * 0.5);
|
||||||
|
p /= zoomAmount;
|
||||||
|
|
||||||
|
// Radial distortion synchronized with pump
|
||||||
|
float dist = length(p);
|
||||||
|
float angle = atan2(p.y, p.x);
|
||||||
|
|
||||||
|
// Pump-synced radial waves
|
||||||
|
float radialWave = sin(dist * 15.0 - time * 3.0 + envelope * 10.0);
|
||||||
|
radialWave *= pump * 0.3;
|
||||||
|
|
||||||
|
// Apply distortion
|
||||||
|
float2 distortedP = p;
|
||||||
|
distortedP *= 1.0 + radialWave * 0.1;
|
||||||
|
|
||||||
|
// Create concentric pulse rings
|
||||||
|
float rings = 0.0;
|
||||||
|
const int numRings = 5;
|
||||||
|
|
||||||
|
for (int i = 0; i < numRings; i++) {
|
||||||
|
float ringPhase = fract(time * 0.5 + float(i) * 0.2 - envelope * 0.5);
|
||||||
|
float ringRadius = ringPhase * 0.6;
|
||||||
|
float ringWidth = 0.02 + pump * 0.03;
|
||||||
|
|
||||||
|
float ringDist = abs(dist - ringRadius);
|
||||||
|
float ring = exp(-ringDist * ringDist / (ringWidth * ringWidth));
|
||||||
|
ring *= 1.0 - ringPhase; // Fade out as it expands
|
||||||
|
ring *= pump;
|
||||||
|
|
||||||
|
rings += ring;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Breathing glow in center
|
||||||
|
float breathIntensity = 0.5 + 0.5 * sin(time * 4.0 + envelope * 6.28318);
|
||||||
|
breathIntensity *= pump;
|
||||||
|
|
||||||
|
float centerGlow = exp(-dist * dist * 8.0);
|
||||||
|
centerGlow *= breathIntensity;
|
||||||
|
|
||||||
|
// Color based on pump phase
|
||||||
|
float3 pumpColor = mix(uvViolet, neonMagenta, envelope);
|
||||||
|
float3 ringColor = mix(neonCyan, hotPink, pump);
|
||||||
|
|
||||||
|
// Background pattern - angular sectors that pulse
|
||||||
|
float sectors = 8.0;
|
||||||
|
float sectorAngle = fract(angle / (2.0 * 3.14159) * sectors);
|
||||||
|
float sectorPulse = smoothstep(0.4, 0.5, sectorAngle) - smoothstep(0.5, 0.6, sectorAngle);
|
||||||
|
sectorPulse *= pump * 0.3;
|
||||||
|
sectorPulse *= exp(-dist * 3.0);
|
||||||
|
|
||||||
|
// Spiral pattern
|
||||||
|
float spiral = fract(angle / (2.0 * 3.14159) * 3.0 + dist * 5.0 - time * 0.5);
|
||||||
|
spiral = smoothstep(0.4, 0.5, spiral) - smoothstep(0.5, 0.6, spiral);
|
||||||
|
spiral *= pump * 0.2;
|
||||||
|
spiral *= exp(-dist * 2.0);
|
||||||
|
|
||||||
|
// Compose final color
|
||||||
|
float3 finalColor = float3(0.0);
|
||||||
|
|
||||||
|
// Base gradient
|
||||||
|
float3 bgGradient = mix(deepPurple, uvViolet * 0.3, dist);
|
||||||
|
finalColor += bgGradient;
|
||||||
|
|
||||||
|
// Add rings
|
||||||
|
finalColor += ringColor * rings;
|
||||||
|
|
||||||
|
// Add center glow
|
||||||
|
finalColor += pumpColor * centerGlow;
|
||||||
|
|
||||||
|
// Add sector pulse
|
||||||
|
finalColor += neonGreen * sectorPulse;
|
||||||
|
|
||||||
|
// Add spiral
|
||||||
|
finalColor += electricBlue * spiral;
|
||||||
|
|
||||||
|
// Screen flash on strong pump
|
||||||
|
if (pump > 0.7) {
|
||||||
|
float flash = (pump - 0.7) / 0.3;
|
||||||
|
flash *= 0.2;
|
||||||
|
finalColor += neonMagenta * flash;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peak highlight
|
||||||
|
if (uniforms.isPeak > 0.5) {
|
||||||
|
float peakFlash = uniforms.peakIntensity * 0.2;
|
||||||
|
finalColor += float3(1.0) * peakFlash * exp(-dist * 5.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vignette
|
||||||
|
float vignette = 1.0 - smoothstep(0.4, 0.8, dist);
|
||||||
|
finalColor *= 0.7 + vignette * 0.3;
|
||||||
|
|
||||||
|
return float4(finalColor, 1.0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
//
|
||||||
|
// SubBassShader.metal
|
||||||
|
// PsytranceVisualizer
|
||||||
|
//
|
||||||
|
// Pulsating rings visualizing sub-bass energy below 100Hz
|
||||||
|
//
|
||||||
|
|
||||||
|
#include <metal_stdlib>
|
||||||
|
using namespace metal;
|
||||||
|
|
||||||
|
#include "Common.metal"
|
||||||
|
|
||||||
|
fragment float4 subBassFragment(
|
||||||
|
VertexOut in [[stage_in]],
|
||||||
|
constant ShaderUniforms& uniforms [[buffer(0)]],
|
||||||
|
constant float* fftData [[buffer(1)]],
|
||||||
|
constant float* melData [[buffer(2)]],
|
||||||
|
constant float* historyData [[buffer(3)]]
|
||||||
|
) {
|
||||||
|
float2 uv = in.uv;
|
||||||
|
float2 resolution = uniforms.resolution;
|
||||||
|
float time = uniforms.time;
|
||||||
|
float reactivity = uniforms.reactivity;
|
||||||
|
float subBass = uniforms.subBassEnergy;
|
||||||
|
|
||||||
|
// Center coordinates
|
||||||
|
float2 center = float2(0.5, 0.5);
|
||||||
|
float aspectRatio = resolution.x / resolution.y;
|
||||||
|
|
||||||
|
// Correct for aspect ratio
|
||||||
|
float2 p = uv - center;
|
||||||
|
p.x *= aspectRatio;
|
||||||
|
|
||||||
|
float dist = length(p);
|
||||||
|
float angle = atan2(p.y, p.x);
|
||||||
|
|
||||||
|
// Main pulsating circle
|
||||||
|
float baseRadius = 0.15;
|
||||||
|
float pulseAmount = subBass * (0.5 + reactivity * 0.5);
|
||||||
|
float mainRadius = baseRadius + pulseAmount * 0.2;
|
||||||
|
|
||||||
|
// Add wobble based on angle
|
||||||
|
float wobble = sin(angle * 4.0 + time * 2.0) * 0.02 * subBass;
|
||||||
|
mainRadius += wobble;
|
||||||
|
|
||||||
|
// Core circle
|
||||||
|
float coreDist = abs(dist - mainRadius);
|
||||||
|
float coreGlow = exp(-coreDist * coreDist * 200.0);
|
||||||
|
|
||||||
|
// Inner fill with gradient
|
||||||
|
float innerFill = smoothstep(mainRadius, mainRadius * 0.3, dist);
|
||||||
|
innerFill *= 0.5 + 0.5 * subBass;
|
||||||
|
|
||||||
|
// Expanding rings
|
||||||
|
const int numRings = 6;
|
||||||
|
float ringIntensity = 0.0;
|
||||||
|
|
||||||
|
for (int i = 0; i < numRings; i++) {
|
||||||
|
// Each ring expands outward over time
|
||||||
|
float ringPhase = fract(time * 0.3 - float(i) * 0.15);
|
||||||
|
float ringRadius = mainRadius + ringPhase * 0.5;
|
||||||
|
|
||||||
|
// Get historical sub-bass value for this ring
|
||||||
|
int histIndex = clamp(int(ringPhase * 64.0), 0, 63);
|
||||||
|
float histValue = historyData[histIndex];
|
||||||
|
|
||||||
|
// Ring thickness based on historical energy
|
||||||
|
float thickness = 0.005 + histValue * 0.01;
|
||||||
|
float ringDist = abs(dist - ringRadius);
|
||||||
|
|
||||||
|
// Ring visibility
|
||||||
|
float ring = exp(-ringDist * ringDist / (thickness * thickness));
|
||||||
|
ring *= (1.0 - ringPhase); // Fade as it expands
|
||||||
|
ring *= histValue; // Intensity based on history
|
||||||
|
|
||||||
|
ringIntensity += ring;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color composition
|
||||||
|
float3 coreColor = mix(uvViolet, neonMagenta, subBass);
|
||||||
|
float3 ringColor = mix(neonMagenta, hotPink, 0.5 + 0.5 * sin(time));
|
||||||
|
|
||||||
|
float3 finalColor = float3(0.0);
|
||||||
|
|
||||||
|
// Add core
|
||||||
|
finalColor += coreColor * (innerFill + coreGlow * 2.0);
|
||||||
|
|
||||||
|
// Add rings
|
||||||
|
finalColor += ringColor * ringIntensity * 0.8;
|
||||||
|
|
||||||
|
// Add central glow
|
||||||
|
float centerGlow = exp(-dist * dist * 10.0) * subBass;
|
||||||
|
finalColor += uvViolet * centerGlow * 0.5;
|
||||||
|
|
||||||
|
// Add angular rays on peaks
|
||||||
|
if (uniforms.isPeak > 0.5) {
|
||||||
|
float rays = abs(sin(angle * 8.0 + time * 5.0));
|
||||||
|
rays = pow(rays, 4.0) * exp(-dist * 2.0);
|
||||||
|
rays *= uniforms.peakIntensity;
|
||||||
|
finalColor += neonCyan * rays * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outer vignette
|
||||||
|
float vignette = 1.0 - smoothstep(0.3, 0.8, dist);
|
||||||
|
finalColor *= vignette;
|
||||||
|
|
||||||
|
// Background pulse
|
||||||
|
float bgPulse = subBass * 0.1;
|
||||||
|
finalColor += deepPurple * bgPulse;
|
||||||
|
|
||||||
|
// Add noise texture for organic feel
|
||||||
|
float noiseVal = noise(p * 20.0 + time);
|
||||||
|
finalColor += uvViolet * noiseVal * 0.02 * subBass;
|
||||||
|
|
||||||
|
return float4(finalColor, 1.0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
//
|
||||||
|
// TunnelWarpShader.metal
|
||||||
|
// PsytranceVisualizer
|
||||||
|
//
|
||||||
|
// Infinite tunnel effect with warp distortion
|
||||||
|
//
|
||||||
|
|
||||||
|
#include <metal_stdlib>
|
||||||
|
using namespace metal;
|
||||||
|
|
||||||
|
#include "Common.metal"
|
||||||
|
|
||||||
|
fragment float4 tunnelWarpFragment(
|
||||||
|
VertexOut in [[stage_in]],
|
||||||
|
constant ShaderUniforms& uniforms [[buffer(0)]],
|
||||||
|
constant float* fftData [[buffer(1)]],
|
||||||
|
constant float* melData [[buffer(2)]],
|
||||||
|
constant float* historyData [[buffer(3)]]
|
||||||
|
) {
|
||||||
|
float2 uv = in.uv;
|
||||||
|
float2 resolution = uniforms.resolution;
|
||||||
|
float time = uniforms.time;
|
||||||
|
float reactivity = uniforms.reactivity;
|
||||||
|
|
||||||
|
float subBass = uniforms.subBassEnergy;
|
||||||
|
float pump = uniforms.sidechainPump;
|
||||||
|
float hnr = uniforms.hnrRatio;
|
||||||
|
|
||||||
|
// Center and aspect correction
|
||||||
|
float aspectRatio = resolution.x / resolution.y;
|
||||||
|
float2 p = (uv - 0.5) * 2.0;
|
||||||
|
p.x *= aspectRatio;
|
||||||
|
|
||||||
|
// Convert to polar coordinates for tunnel
|
||||||
|
float dist = length(p);
|
||||||
|
float angle = atan2(p.y, p.x);
|
||||||
|
|
||||||
|
// Avoid division by zero at center
|
||||||
|
dist = max(dist, 0.001);
|
||||||
|
|
||||||
|
// Tunnel depth (inverse of distance)
|
||||||
|
float depth = 1.0 / dist;
|
||||||
|
|
||||||
|
// Speed controlled by sub-bass
|
||||||
|
float baseSpeed = 2.0;
|
||||||
|
float audioSpeed = subBass * 3.0 * (0.5 + reactivity * 0.5);
|
||||||
|
float speed = baseSpeed + audioSpeed;
|
||||||
|
|
||||||
|
// Warp distortion from sidechain pump
|
||||||
|
float warpAmount = pump * 0.5;
|
||||||
|
depth += sin(angle * 4.0 + time * 2.0) * warpAmount * 0.5;
|
||||||
|
angle += sin(depth * 2.0 + time) * warpAmount * 0.3;
|
||||||
|
|
||||||
|
// Create tunnel coordinates
|
||||||
|
float2 tunnelUV = float2(
|
||||||
|
angle / (2.0 * 3.14159) + 0.5, // Angular coordinate [0, 1]
|
||||||
|
depth + time * speed // Depth with movement
|
||||||
|
);
|
||||||
|
|
||||||
|
// === TUNNEL WALL PATTERNS ===
|
||||||
|
|
||||||
|
// Hexagonal grid pattern
|
||||||
|
float2 hexUV = tunnelUV * float2(8.0, 2.0);
|
||||||
|
float2 hexCell = floor(hexUV);
|
||||||
|
float2 hexFrac = fract(hexUV);
|
||||||
|
|
||||||
|
// Offset every other row
|
||||||
|
if (fmod(hexCell.y, 2.0) > 0.5) {
|
||||||
|
hexFrac.x = fract(hexFrac.x + 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
float hexDist = length(hexFrac - 0.5);
|
||||||
|
float hexPattern = smoothstep(0.4, 0.35, hexDist);
|
||||||
|
|
||||||
|
// Add concentric rings
|
||||||
|
float rings = sin(tunnelUV.y * 20.0) * 0.5 + 0.5;
|
||||||
|
rings = smoothstep(0.3, 0.7, rings);
|
||||||
|
|
||||||
|
// Angular segments
|
||||||
|
float segments = 8.0;
|
||||||
|
float angularLines = abs(sin(angle * segments));
|
||||||
|
angularLines = smoothstep(0.95, 1.0, angularLines);
|
||||||
|
|
||||||
|
// Combine patterns
|
||||||
|
float pattern = hexPattern * 0.5 + rings * 0.3 + angularLines * 0.2;
|
||||||
|
|
||||||
|
// === COLORING ===
|
||||||
|
|
||||||
|
// Base color cycles with depth and time
|
||||||
|
float colorPhase = tunnelUV.y * 0.1 + time * 0.2;
|
||||||
|
float3 tunnelColor = psytrancePalette(colorPhase, time);
|
||||||
|
|
||||||
|
// Depth fog (darker towards center/infinity)
|
||||||
|
float fog = exp(-dist * 2.0);
|
||||||
|
tunnelColor *= fog;
|
||||||
|
|
||||||
|
// Pattern overlay
|
||||||
|
float3 patternColor = mix(uvViolet, neonCyan, rings);
|
||||||
|
tunnelColor = mix(tunnelColor, patternColor, pattern * 0.5);
|
||||||
|
|
||||||
|
// Edge glow (bright at tunnel edges)
|
||||||
|
float edgeGlow = exp(-dist * 5.0);
|
||||||
|
tunnelColor = addGlow(tunnelColor, (1.0 - edgeGlow) * 0.3, neonMagenta);
|
||||||
|
|
||||||
|
// Center light (looking into the tunnel)
|
||||||
|
float centerLight = exp(-dist * dist * 50.0);
|
||||||
|
tunnelColor += float3(1.0) * centerLight * 0.5;
|
||||||
|
|
||||||
|
// HNR affects pattern complexity
|
||||||
|
float patternIntensity = hnr;
|
||||||
|
tunnelColor *= 0.7 + patternIntensity * 0.3;
|
||||||
|
|
||||||
|
// Add noise for texture
|
||||||
|
float noiseVal = noise(tunnelUV * 10.0 + time);
|
||||||
|
tunnelColor += uvViolet * noiseVal * 0.1;
|
||||||
|
|
||||||
|
// Pump flash
|
||||||
|
if (pump > 0.5) {
|
||||||
|
float pumpFlash = (pump - 0.5) * 2.0;
|
||||||
|
tunnelColor += neonMagenta * pumpFlash * 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peak flash
|
||||||
|
if (uniforms.isPeak > 0.5) {
|
||||||
|
float peakFlash = uniforms.peakIntensity;
|
||||||
|
tunnelColor += float3(1.0) * peakFlash * 0.15 * (1.0 - edgeGlow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speed lines effect
|
||||||
|
float speedLines = fract(tunnelUV.y * 50.0 - time * speed * 2.0);
|
||||||
|
speedLines = smoothstep(0.95, 1.0, speedLines);
|
||||||
|
speedLines *= subBass * 0.5;
|
||||||
|
tunnelColor += neonCyan * speedLines;
|
||||||
|
|
||||||
|
return float4(tunnelColor, 1.0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "1.000",
|
||||||
|
"green" : "0.000",
|
||||||
|
"red" : "1.000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "128x128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "128x128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "512x512"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string></string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>LSApplicationCategoryType</key>
|
||||||
|
<string>public.app-category.music</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>Copyright © 2024. All rights reserved.</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Psytrance Visualizer needs access to your audio input to visualize music in real-time. You can use a virtual audio device like BlackHole to route system audio.</string>
|
||||||
|
<key>NSPrincipalClass</key>
|
||||||
|
<string>NSApplication</string>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSSupportsAutomaticGraphicsSwitching</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.app-sandbox</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.files.user-selected.read-write</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
//
|
||||||
|
// ControlPanel.swift
|
||||||
|
// PsytranceVisualizer
|
||||||
|
//
|
||||||
|
// Auto-hiding control panel with audio and visualization settings
|
||||||
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Delegate protocol for control panel actions
|
||||||
|
protocol ControlPanelDelegate: AnyObject {
|
||||||
|
func controlPanel(_ panel: ControlPanel, didSelectDevice uid: String)
|
||||||
|
func controlPanel(_ panel: ControlPanel, didSelectBufferSize size: Int)
|
||||||
|
func controlPanel(_ panel: ControlPanel, didSelectMode mode: VisualizationMode)
|
||||||
|
func controlPanel(_ panel: ControlPanel, didChangeReactivity value: Float)
|
||||||
|
func controlPanelDidRequestFullscreen(_ panel: ControlPanel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auto-hiding control panel overlay
|
||||||
|
final class ControlPanel: NSView {
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
weak var delegate: ControlPanelDelegate?
|
||||||
|
|
||||||
|
private var isVisible = true
|
||||||
|
private var hideTimer: Timer?
|
||||||
|
private let hideDelay: TimeInterval = 3.0
|
||||||
|
|
||||||
|
private var audioDevices: [AudioDevice] = []
|
||||||
|
private var selectedMode: VisualizationMode = .fftClassic
|
||||||
|
|
||||||
|
// MARK: - UI Elements
|
||||||
|
|
||||||
|
private let containerView = NSVisualEffectView()
|
||||||
|
private let devicePopup = NSPopUpButton()
|
||||||
|
private let bufferSizePopup = NSPopUpButton()
|
||||||
|
private let modeSegment = NSSegmentedControl()
|
||||||
|
private let reactivitySlider = NSSlider()
|
||||||
|
private let reactivityLabel = NSTextField(labelWithString: "Reactivity")
|
||||||
|
private let fullscreenButton = NSButton()
|
||||||
|
|
||||||
|
// MARK: - Layout Constants
|
||||||
|
|
||||||
|
private let panelHeight: CGFloat = 60
|
||||||
|
private let padding: CGFloat = 12
|
||||||
|
private let elementHeight: CGFloat = 24
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
override init(frame frameRect: NSRect) {
|
||||||
|
super.init(frame: frameRect)
|
||||||
|
setupUI()
|
||||||
|
setupConstraints()
|
||||||
|
startHideTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Setup
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
// Container with vibrancy effect
|
||||||
|
containerView.material = .hudWindow
|
||||||
|
containerView.blendingMode = .behindWindow
|
||||||
|
containerView.state = .active
|
||||||
|
containerView.wantsLayer = true
|
||||||
|
containerView.layer?.cornerRadius = 12
|
||||||
|
containerView.layer?.masksToBounds = true
|
||||||
|
addSubview(containerView)
|
||||||
|
|
||||||
|
// Device popup
|
||||||
|
devicePopup.target = self
|
||||||
|
devicePopup.action = #selector(deviceChanged)
|
||||||
|
devicePopup.controlSize = .small
|
||||||
|
devicePopup.font = .systemFont(ofSize: 11)
|
||||||
|
containerView.addSubview(devicePopup)
|
||||||
|
|
||||||
|
// Buffer size popup
|
||||||
|
bufferSizePopup.target = self
|
||||||
|
bufferSizePopup.action = #selector(bufferSizeChanged)
|
||||||
|
bufferSizePopup.controlSize = .small
|
||||||
|
bufferSizePopup.font = .systemFont(ofSize: 11)
|
||||||
|
bufferSizePopup.addItems(withTitles: ["512", "1024"])
|
||||||
|
bufferSizePopup.selectItem(withTitle: "1024")
|
||||||
|
containerView.addSubview(bufferSizePopup)
|
||||||
|
|
||||||
|
// Mode segment control
|
||||||
|
modeSegment.segmentCount = 8
|
||||||
|
for mode in VisualizationMode.allCases {
|
||||||
|
modeSegment.setLabel(mode.shortcut, forSegment: mode.rawValue - 1)
|
||||||
|
modeSegment.setToolTip(mode.displayName, forSegment: mode.rawValue - 1)
|
||||||
|
}
|
||||||
|
modeSegment.selectedSegment = 0
|
||||||
|
modeSegment.target = self
|
||||||
|
modeSegment.action = #selector(modeChanged)
|
||||||
|
modeSegment.controlSize = .small
|
||||||
|
modeSegment.segmentStyle = .capsule
|
||||||
|
containerView.addSubview(modeSegment)
|
||||||
|
|
||||||
|
// Reactivity label
|
||||||
|
reactivityLabel.font = .systemFont(ofSize: 10)
|
||||||
|
reactivityLabel.textColor = .secondaryLabelColor
|
||||||
|
containerView.addSubview(reactivityLabel)
|
||||||
|
|
||||||
|
// Reactivity slider
|
||||||
|
reactivitySlider.minValue = 0.0
|
||||||
|
reactivitySlider.maxValue = 1.0
|
||||||
|
reactivitySlider.doubleValue = 0.5
|
||||||
|
reactivitySlider.target = self
|
||||||
|
reactivitySlider.action = #selector(reactivityChanged)
|
||||||
|
reactivitySlider.controlSize = .small
|
||||||
|
containerView.addSubview(reactivitySlider)
|
||||||
|
|
||||||
|
// Fullscreen button
|
||||||
|
fullscreenButton.title = "⛶"
|
||||||
|
fullscreenButton.bezelStyle = .accessoryBarAction
|
||||||
|
fullscreenButton.target = self
|
||||||
|
fullscreenButton.action = #selector(fullscreenClicked)
|
||||||
|
fullscreenButton.toolTip = "Toggle Fullscreen (F)"
|
||||||
|
containerView.addSubview(fullscreenButton)
|
||||||
|
|
||||||
|
// Set colors
|
||||||
|
applyPsytranceTheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyPsytranceTheme() {
|
||||||
|
// Custom appearance for psytrance aesthetic
|
||||||
|
containerView.appearance = NSAppearance(named: .darkAqua)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupConstraints() {
|
||||||
|
containerView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
devicePopup.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
bufferSizePopup.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
modeSegment.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
reactivityLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
reactivitySlider.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
fullscreenButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
// Container
|
||||||
|
containerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: padding),
|
||||||
|
containerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -padding),
|
||||||
|
containerView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -padding),
|
||||||
|
containerView.heightAnchor.constraint(equalToConstant: panelHeight),
|
||||||
|
|
||||||
|
// Device popup
|
||||||
|
devicePopup.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: padding),
|
||||||
|
devicePopup.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
|
||||||
|
devicePopup.widthAnchor.constraint(equalToConstant: 150),
|
||||||
|
|
||||||
|
// Buffer size popup
|
||||||
|
bufferSizePopup.leadingAnchor.constraint(equalTo: devicePopup.trailingAnchor, constant: 8),
|
||||||
|
bufferSizePopup.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
|
||||||
|
bufferSizePopup.widthAnchor.constraint(equalToConstant: 60),
|
||||||
|
|
||||||
|
// Mode segment
|
||||||
|
modeSegment.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
|
||||||
|
modeSegment.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
|
||||||
|
|
||||||
|
// Reactivity label
|
||||||
|
reactivityLabel.trailingAnchor.constraint(equalTo: reactivitySlider.leadingAnchor, constant: -4),
|
||||||
|
reactivityLabel.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
|
||||||
|
|
||||||
|
// Reactivity slider
|
||||||
|
reactivitySlider.trailingAnchor.constraint(equalTo: fullscreenButton.leadingAnchor, constant: -padding),
|
||||||
|
reactivitySlider.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
|
||||||
|
reactivitySlider.widthAnchor.constraint(equalToConstant: 80),
|
||||||
|
|
||||||
|
// Fullscreen button
|
||||||
|
fullscreenButton.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -padding),
|
||||||
|
fullscreenButton.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Methods
|
||||||
|
|
||||||
|
/// Updates the list of available audio devices
|
||||||
|
func updateDevices(_ devices: [AudioDevice], selectedUID: String?) {
|
||||||
|
audioDevices = devices
|
||||||
|
devicePopup.removeAllItems()
|
||||||
|
|
||||||
|
for device in devices {
|
||||||
|
devicePopup.addItem(withTitle: device.name)
|
||||||
|
devicePopup.lastItem?.representedObject = device.uid
|
||||||
|
}
|
||||||
|
|
||||||
|
if let uid = selectedUID,
|
||||||
|
let index = devices.firstIndex(where: { $0.uid == uid }) {
|
||||||
|
devicePopup.selectItem(at: index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the selected buffer size
|
||||||
|
func updateBufferSize(_ size: Int) {
|
||||||
|
bufferSizePopup.selectItem(withTitle: "\(size)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the selected visualization mode
|
||||||
|
func updateMode(_ mode: VisualizationMode) {
|
||||||
|
selectedMode = mode
|
||||||
|
modeSegment.selectedSegment = mode.rawValue - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the reactivity slider
|
||||||
|
func updateReactivity(_ value: Float) {
|
||||||
|
reactivitySlider.doubleValue = Double(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows the control panel
|
||||||
|
func show(animated: Bool = true) {
|
||||||
|
guard !isVisible else { return }
|
||||||
|
isVisible = true
|
||||||
|
|
||||||
|
if animated {
|
||||||
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
|
context.duration = 0.3
|
||||||
|
self.animator().alphaValue = 1.0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alphaValue = 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
startHideTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hides the control panel
|
||||||
|
func hide(animated: Bool = true) {
|
||||||
|
guard isVisible else { return }
|
||||||
|
isVisible = false
|
||||||
|
hideTimer?.invalidate()
|
||||||
|
|
||||||
|
if animated {
|
||||||
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
|
context.duration = 0.3
|
||||||
|
self.animator().alphaValue = 0.0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alphaValue = 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resets the hide timer (call on mouse movement)
|
||||||
|
func resetHideTimer() {
|
||||||
|
show()
|
||||||
|
startHideTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
|
||||||
|
private func startHideTimer() {
|
||||||
|
hideTimer?.invalidate()
|
||||||
|
hideTimer = Timer.scheduledTimer(withTimeInterval: hideDelay, repeats: false) { [weak self] _ in
|
||||||
|
self?.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
@objc private func deviceChanged() {
|
||||||
|
guard let uid = devicePopup.selectedItem?.representedObject as? String else { return }
|
||||||
|
delegate?.controlPanel(self, didSelectDevice: uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func bufferSizeChanged() {
|
||||||
|
guard let title = bufferSizePopup.selectedItem?.title,
|
||||||
|
let size = Int(title) else { return }
|
||||||
|
delegate?.controlPanel(self, didSelectBufferSize: size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func modeChanged() {
|
||||||
|
let modeIndex = modeSegment.selectedSegment + 1
|
||||||
|
guard let mode = VisualizationMode(rawValue: modeIndex) else { return }
|
||||||
|
selectedMode = mode
|
||||||
|
delegate?.controlPanel(self, didSelectMode: mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func reactivityChanged() {
|
||||||
|
let value = Float(reactivitySlider.doubleValue)
|
||||||
|
delegate?.controlPanel(self, didChangeReactivity: value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func fullscreenClicked() {
|
||||||
|
delegate?.controlPanelDidRequestFullscreen(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mouse Tracking
|
||||||
|
|
||||||
|
override func updateTrackingAreas() {
|
||||||
|
super.updateTrackingAreas()
|
||||||
|
|
||||||
|
// Remove existing tracking areas
|
||||||
|
for area in trackingAreas {
|
||||||
|
removeTrackingArea(area)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new tracking area
|
||||||
|
let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .mouseMoved, .activeAlways]
|
||||||
|
let trackingArea = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil)
|
||||||
|
addTrackingArea(trackingArea)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseMoved(with event: NSEvent) {
|
||||||
|
super.mouseMoved(with: event)
|
||||||
|
resetHideTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseEntered(with event: NSEvent) {
|
||||||
|
super.mouseEntered(with: event)
|
||||||
|
show()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
//
|
||||||
|
// MainWindow.swift
|
||||||
|
// PsytranceVisualizer
|
||||||
|
//
|
||||||
|
// Main application window with keyboard handling
|
||||||
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Main window controller for the visualizer
|
||||||
|
final class MainWindowController: NSWindowController {
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
private var visualizerView: VisualizerView!
|
||||||
|
private var controlPanel: ControlPanel!
|
||||||
|
|
||||||
|
private var audioManager: AudioInputManager!
|
||||||
|
private var dspEngine: DSPEngine!
|
||||||
|
private var settingsManager: SettingsManager { .shared }
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private var displayLink: CVDisplayLink?
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
convenience init() {
|
||||||
|
let window = NSWindow(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 1280, height: 720),
|
||||||
|
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
|
||||||
|
window.title = "Psytrance Visualizer"
|
||||||
|
window.minSize = NSSize(width: 800, height: 600)
|
||||||
|
window.titlebarAppearsTransparent = true
|
||||||
|
window.titleVisibility = .hidden
|
||||||
|
window.isMovableByWindowBackground = true
|
||||||
|
window.backgroundColor = .black
|
||||||
|
window.collectionBehavior = [.fullScreenPrimary]
|
||||||
|
|
||||||
|
// Restore window frame if saved
|
||||||
|
if let savedFrame = SettingsManager.shared.settings.windowFrame?.cgRect {
|
||||||
|
window.setFrame(savedFrame, display: false)
|
||||||
|
} else {
|
||||||
|
window.center()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.init(window: window)
|
||||||
|
|
||||||
|
setupContent()
|
||||||
|
setupAudio()
|
||||||
|
setupKeyboardHandling()
|
||||||
|
restoreSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Setup
|
||||||
|
|
||||||
|
private func setupContent() {
|
||||||
|
guard let contentView = window?.contentView else { return }
|
||||||
|
|
||||||
|
// Visualizer view (fills entire window)
|
||||||
|
visualizerView = VisualizerView()
|
||||||
|
visualizerView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
contentView.addSubview(visualizerView)
|
||||||
|
|
||||||
|
// Control panel (overlay at bottom)
|
||||||
|
controlPanel = ControlPanel()
|
||||||
|
controlPanel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
controlPanel.delegate = self
|
||||||
|
contentView.addSubview(controlPanel)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
// Visualizer fills entire window
|
||||||
|
visualizerView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||||
|
visualizerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||||
|
visualizerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||||
|
visualizerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||||
|
|
||||||
|
// Control panel at bottom
|
||||||
|
controlPanel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||||
|
controlPanel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||||
|
controlPanel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||||
|
controlPanel.heightAnchor.constraint(equalToConstant: 90),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Mouse tracking for control panel
|
||||||
|
setupMouseTracking()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupAudio() {
|
||||||
|
audioManager = AudioInputManager()
|
||||||
|
dspEngine = DSPEngine(bufferSize: settingsManager.settings.bufferSize)
|
||||||
|
|
||||||
|
// Audio buffer callback
|
||||||
|
audioManager.onAudioBuffer = { [weak self] buffer in
|
||||||
|
guard let self = self else { return }
|
||||||
|
let analysisData = self.dspEngine.process(buffer: buffer)
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.visualizerView.updateAudioData(analysisData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update control panel when devices change
|
||||||
|
audioManager.$availableDevices
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] devices in
|
||||||
|
self?.controlPanel.updateDevices(
|
||||||
|
devices,
|
||||||
|
selectedUID: self?.settingsManager.settings.selectedAudioDeviceUID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// Start audio
|
||||||
|
audioManager.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupKeyboardHandling() {
|
||||||
|
// Monitor for key events
|
||||||
|
NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
||||||
|
if self?.handleKeyDown(event) == true {
|
||||||
|
return nil // Event handled
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupMouseTracking() {
|
||||||
|
guard let contentView = window?.contentView else { return }
|
||||||
|
|
||||||
|
let options: NSTrackingArea.Options = [.mouseMoved, .activeAlways, .inVisibleRect]
|
||||||
|
let trackingArea = NSTrackingArea(
|
||||||
|
rect: contentView.bounds,
|
||||||
|
options: options,
|
||||||
|
owner: self,
|
||||||
|
userInfo: nil
|
||||||
|
)
|
||||||
|
contentView.addTrackingArea(trackingArea)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restoreSettings() {
|
||||||
|
let settings = settingsManager.settings
|
||||||
|
|
||||||
|
// Restore visualization mode
|
||||||
|
if let mode = VisualizationMode(rawValue: settings.lastVisualizationMode) {
|
||||||
|
visualizerView.setVisualizationMode(mode)
|
||||||
|
controlPanel.updateMode(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore reactivity
|
||||||
|
visualizerView.setReactivity(settings.reactivity)
|
||||||
|
dspEngine.setReactivity(settings.reactivity)
|
||||||
|
controlPanel.updateReactivity(settings.reactivity)
|
||||||
|
|
||||||
|
// Restore buffer size
|
||||||
|
dspEngine.setBufferSize(settings.bufferSize)
|
||||||
|
audioManager.setBufferSize(settings.bufferSize)
|
||||||
|
controlPanel.updateBufferSize(settings.bufferSize)
|
||||||
|
|
||||||
|
// Restore audio device
|
||||||
|
if let deviceUID = settings.selectedAudioDeviceUID {
|
||||||
|
audioManager.selectDevice(uid: deviceUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore fullscreen state
|
||||||
|
if settings.isFullscreen {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||||
|
self?.window?.toggleFullScreen(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Keyboard Handling
|
||||||
|
|
||||||
|
private func handleKeyDown(_ event: NSEvent) -> Bool {
|
||||||
|
// Check for visualization mode shortcuts (1-8)
|
||||||
|
if let mode = VisualizationMode.fromKeyCode(event.keyCode) {
|
||||||
|
setVisualizationMode(mode)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other keyboard shortcuts
|
||||||
|
switch event.keyCode {
|
||||||
|
case 3: // F key
|
||||||
|
toggleFullscreen()
|
||||||
|
return true
|
||||||
|
case 53: // Escape
|
||||||
|
if window?.styleMask.contains(.fullScreen) == true {
|
||||||
|
window?.toggleFullScreen(nil)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case 49: // Space
|
||||||
|
// Toggle pause (could be implemented)
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cmd+F for fullscreen
|
||||||
|
if event.modifierFlags.contains(.command) && event.keyCode == 3 {
|
||||||
|
toggleFullscreen()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mode Switching
|
||||||
|
|
||||||
|
private func setVisualizationMode(_ mode: VisualizationMode) {
|
||||||
|
visualizerView.setVisualizationMode(mode)
|
||||||
|
controlPanel.updateMode(mode)
|
||||||
|
settingsManager.setVisualizationMode(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Fullscreen
|
||||||
|
|
||||||
|
private func toggleFullscreen() {
|
||||||
|
window?.toggleFullScreen(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mouse Events
|
||||||
|
|
||||||
|
override func mouseMoved(with event: NSEvent) {
|
||||||
|
controlPanel.resetHideTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Window Events
|
||||||
|
|
||||||
|
override func windowDidLoad() {
|
||||||
|
super.windowDidLoad()
|
||||||
|
|
||||||
|
// Save window frame on move/resize
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(windowDidResize),
|
||||||
|
name: NSWindow.didResizeNotification,
|
||||||
|
object: window
|
||||||
|
)
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(windowDidMove),
|
||||||
|
name: NSWindow.didMoveNotification,
|
||||||
|
object: window
|
||||||
|
)
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(windowDidEnterFullScreen),
|
||||||
|
name: NSWindow.didEnterFullScreenNotification,
|
||||||
|
object: window
|
||||||
|
)
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(windowDidExitFullScreen),
|
||||||
|
name: NSWindow.didExitFullScreenNotification,
|
||||||
|
object: window
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func windowDidResize(_ notification: Notification) {
|
||||||
|
if let frame = window?.frame {
|
||||||
|
settingsManager.setWindowFrame(frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func windowDidMove(_ notification: Notification) {
|
||||||
|
if let frame = window?.frame {
|
||||||
|
settingsManager.setWindowFrame(frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func windowDidEnterFullScreen(_ notification: Notification) {
|
||||||
|
settingsManager.setFullscreen(true)
|
||||||
|
controlPanel.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func windowDidExitFullScreen(_ notification: Notification) {
|
||||||
|
settingsManager.setFullscreen(false)
|
||||||
|
controlPanel.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cleanup
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
audioManager.stop()
|
||||||
|
settingsManager.saveNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ControlPanelDelegate
|
||||||
|
|
||||||
|
extension MainWindowController: ControlPanelDelegate {
|
||||||
|
func controlPanel(_ panel: ControlPanel, didSelectDevice uid: String) {
|
||||||
|
audioManager.selectDevice(uid: uid)
|
||||||
|
settingsManager.setAudioDevice(uid: uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func controlPanel(_ panel: ControlPanel, didSelectBufferSize size: Int) {
|
||||||
|
audioManager.setBufferSize(size)
|
||||||
|
dspEngine.setBufferSize(size)
|
||||||
|
settingsManager.setBufferSize(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
func controlPanel(_ panel: ControlPanel, didSelectMode mode: VisualizationMode) {
|
||||||
|
setVisualizationMode(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func controlPanel(_ panel: ControlPanel, didChangeReactivity value: Float) {
|
||||||
|
visualizerView.setReactivity(value)
|
||||||
|
dspEngine.setReactivity(value)
|
||||||
|
settingsManager.setReactivity(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func controlPanelDidRequestFullscreen(_ panel: ControlPanel) {
|
||||||
|
toggleFullscreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
//
|
||||||
|
// VisualizerView.swift
|
||||||
|
// PsytranceVisualizer
|
||||||
|
//
|
||||||
|
// MTKView subclass for rendering visualizations
|
||||||
|
//
|
||||||
|
|
||||||
|
import MetalKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// MTKView subclass that displays audio-reactive visualizations
|
||||||
|
final class VisualizerView: MTKView {
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
private var renderer: MetalRenderer?
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Get default Metal device
|
||||||
|
guard let device = MTLCreateSystemDefaultDevice() else {
|
||||||
|
fatalError("Metal is not supported on this device")
|
||||||
|
}
|
||||||
|
|
||||||
|
super.init(frame: .zero, device: device)
|
||||||
|
|
||||||
|
configure()
|
||||||
|
setupRenderer()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Configuration
|
||||||
|
|
||||||
|
private func configure() {
|
||||||
|
// Background color
|
||||||
|
clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
|
||||||
|
|
||||||
|
// Color format
|
||||||
|
colorPixelFormat = .bgra8Unorm
|
||||||
|
|
||||||
|
// Enable display link for smooth rendering
|
||||||
|
isPaused = false
|
||||||
|
enableSetNeedsDisplay = false
|
||||||
|
|
||||||
|
// Use display refresh rate
|
||||||
|
preferredFramesPerSecond = 120 // Will cap to display refresh
|
||||||
|
|
||||||
|
// Layer configuration
|
||||||
|
layer?.isOpaque = true
|
||||||
|
|
||||||
|
// Allow high DPI
|
||||||
|
layer?.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupRenderer() {
|
||||||
|
guard let device = device else { return }
|
||||||
|
|
||||||
|
renderer = MetalRenderer(device: device)
|
||||||
|
delegate = renderer
|
||||||
|
|
||||||
|
// Initial size update
|
||||||
|
if let renderer = renderer {
|
||||||
|
let size = drawableSize
|
||||||
|
renderer.mtkView(self, drawableSizeWillChange: size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Methods
|
||||||
|
|
||||||
|
/// Returns the Metal renderer
|
||||||
|
func getRenderer() -> MetalRenderer? {
|
||||||
|
return renderer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates audio data for visualization
|
||||||
|
func updateAudioData(_ data: AudioAnalysisData) {
|
||||||
|
renderer?.updateAudioData(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the visualization mode
|
||||||
|
func setVisualizationMode(_ mode: VisualizationMode) {
|
||||||
|
renderer?.setVisualizationMode(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets reactivity value
|
||||||
|
func setReactivity(_ value: Float) {
|
||||||
|
renderer?.setReactivity(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets current visualization mode
|
||||||
|
var currentMode: VisualizationMode {
|
||||||
|
renderer?.currentMode ?? .fftClassic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SwiftUI Bridge
|
||||||
|
|
||||||
|
#if canImport(SwiftUI)
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// SwiftUI wrapper for VisualizerView
|
||||||
|
struct VisualizerViewRepresentable: NSViewRepresentable {
|
||||||
|
@Binding var audioData: AudioAnalysisData
|
||||||
|
@Binding var mode: VisualizationMode
|
||||||
|
@Binding var reactivity: Float
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> VisualizerView {
|
||||||
|
let view = VisualizerView()
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ nsView: VisualizerView, context: Context) {
|
||||||
|
nsView.updateAudioData(audioData)
|
||||||
|
nsView.setVisualizationMode(mode)
|
||||||
|
nsView.setReactivity(reactivity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
//
|
||||||
|
// ColorPalette.swift
|
||||||
|
// PsytranceVisualizer
|
||||||
|
//
|
||||||
|
// Psytrance color palette for UI and shaders
|
||||||
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
import simd
|
||||||
|
|
||||||
|
/// Psytrance-inspired neon/UV color palette
|
||||||
|
struct PsytranceColors {
|
||||||
|
// MARK: - Primary Colors (NSColor for UI)
|
||||||
|
|
||||||
|
/// Neon Magenta - Primary accent color
|
||||||
|
static let neonMagenta = NSColor(red: 1.0, green: 0.0, blue: 1.0, alpha: 1.0)
|
||||||
|
|
||||||
|
/// Neon Cyan - Secondary accent color
|
||||||
|
static let neonCyan = NSColor(red: 0.0, green: 1.0, blue: 1.0, alpha: 1.0)
|
||||||
|
|
||||||
|
/// Neon Green - High energy accents
|
||||||
|
static let neonGreen = NSColor(red: 0.224, green: 1.0, blue: 0.078, alpha: 1.0)
|
||||||
|
|
||||||
|
/// UV Violet - Deep purple for backgrounds
|
||||||
|
static let uvViolet = NSColor(red: 0.482, green: 0.0, blue: 1.0, alpha: 1.0)
|
||||||
|
|
||||||
|
/// Deep Black - Background color
|
||||||
|
static let background = NSColor(red: 0.0, green: 0.0, blue: 0.0, alpha: 1.0)
|
||||||
|
|
||||||
|
/// Dark Purple - Alternative background
|
||||||
|
static let darkPurple = NSColor(red: 0.1, green: 0.0, blue: 0.15, alpha: 1.0)
|
||||||
|
|
||||||
|
/// Hot Pink - Peak indicators
|
||||||
|
static let hotPink = NSColor(red: 1.0, green: 0.2, blue: 0.6, alpha: 1.0)
|
||||||
|
|
||||||
|
/// Electric Blue - UI elements
|
||||||
|
static let electricBlue = NSColor(red: 0.0, green: 0.5, blue: 1.0, alpha: 1.0)
|
||||||
|
|
||||||
|
// MARK: - SIMD3<Float> Colors (for Metal shaders)
|
||||||
|
|
||||||
|
struct Metal {
|
||||||
|
static let neonMagenta = SIMD3<Float>(1.0, 0.0, 1.0)
|
||||||
|
static let neonCyan = SIMD3<Float>(0.0, 1.0, 1.0)
|
||||||
|
static let neonGreen = SIMD3<Float>(0.224, 1.0, 0.078)
|
||||||
|
static let uvViolet = SIMD3<Float>(0.482, 0.0, 1.0)
|
||||||
|
static let background = SIMD3<Float>(0.0, 0.0, 0.0)
|
||||||
|
static let darkPurple = SIMD3<Float>(0.1, 0.0, 0.15)
|
||||||
|
static let hotPink = SIMD3<Float>(1.0, 0.2, 0.6)
|
||||||
|
static let electricBlue = SIMD3<Float>(0.0, 0.5, 1.0)
|
||||||
|
|
||||||
|
/// Array of all palette colors for cycling
|
||||||
|
static let palette: [SIMD3<Float>] = [
|
||||||
|
neonMagenta,
|
||||||
|
neonCyan,
|
||||||
|
neonGreen,
|
||||||
|
uvViolet,
|
||||||
|
hotPink,
|
||||||
|
electricBlue
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Get color from palette by index (wraps around)
|
||||||
|
static func color(at index: Int) -> SIMD3<Float> {
|
||||||
|
palette[index % palette.count]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Interpolate between two colors
|
||||||
|
static func lerp(_ a: SIMD3<Float>, _ b: SIMD3<Float>, t: Float) -> SIMD3<Float> {
|
||||||
|
a + (b - a) * t
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get rainbow color from normalized value (0-1)
|
||||||
|
static func rainbow(_ t: Float) -> SIMD3<Float> {
|
||||||
|
let index = Int(t * Float(palette.count))
|
||||||
|
let nextIndex = (index + 1) % palette.count
|
||||||
|
let localT = (t * Float(palette.count)) - Float(index)
|
||||||
|
return lerp(palette[index % palette.count], palette[nextIndex], t: localT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Gradient Helpers
|
||||||
|
|
||||||
|
/// Creates a gradient from UV Violet through Magenta to Cyan
|
||||||
|
static var spectrumGradient: NSGradient? {
|
||||||
|
NSGradient(colors: [uvViolet, neonMagenta, hotPink, neonCyan, neonGreen])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a gradient for heat maps (low to high energy)
|
||||||
|
static var heatmapGradient: NSGradient? {
|
||||||
|
NSGradient(colors: [
|
||||||
|
NSColor(red: 0.1, green: 0.0, blue: 0.2, alpha: 1.0), // Dark purple (low)
|
||||||
|
uvViolet,
|
||||||
|
neonMagenta,
|
||||||
|
hotPink,
|
||||||
|
neonCyan,
|
||||||
|
neonGreen,
|
||||||
|
NSColor.white // White (peak)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UI Theme Colors
|
||||||
|
|
||||||
|
struct UI {
|
||||||
|
static let panelBackground = NSColor(red: 0.05, green: 0.02, blue: 0.08, alpha: 0.9)
|
||||||
|
static let buttonBackground = NSColor(red: 0.15, green: 0.05, blue: 0.2, alpha: 1.0)
|
||||||
|
static let buttonHighlight = neonMagenta.withAlphaComponent(0.8)
|
||||||
|
static let sliderTint = neonCyan
|
||||||
|
static let labelText = NSColor.white
|
||||||
|
static let secondaryText = NSColor(white: 0.7, alpha: 1.0)
|
||||||
|
static let border = uvViolet.withAlphaComponent(0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - NSColor Extension
|
||||||
|
|
||||||
|
extension NSColor {
|
||||||
|
/// Converts NSColor to SIMD3<Float> for Metal
|
||||||
|
var simd3: SIMD3<Float> {
|
||||||
|
guard let rgb = usingColorSpace(.deviceRGB) else {
|
||||||
|
return SIMD3<Float>(0, 0, 0)
|
||||||
|
}
|
||||||
|
return SIMD3<Float>(
|
||||||
|
Float(rgb.redComponent),
|
||||||
|
Float(rgb.greenComponent),
|
||||||
|
Float(rgb.blueComponent)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts NSColor to SIMD4<Float> for Metal (with alpha)
|
||||||
|
var simd4: SIMD4<Float> {
|
||||||
|
guard let rgb = usingColorSpace(.deviceRGB) else {
|
||||||
|
return SIMD4<Float>(0, 0, 0, 1)
|
||||||
|
}
|
||||||
|
return SIMD4<Float>(
|
||||||
|
Float(rgb.redComponent),
|
||||||
|
Float(rgb.greenComponent),
|
||||||
|
Float(rgb.blueComponent),
|
||||||
|
Float(rgb.alphaComponent)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
//
|
||||||
|
// SettingsManager.swift
|
||||||
|
// PsytranceVisualizer
|
||||||
|
//
|
||||||
|
// Handles loading and saving of application settings
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Manages persistent storage and retrieval of application settings
|
||||||
|
final class SettingsManager: ObservableObject {
|
||||||
|
// MARK: - Singleton
|
||||||
|
|
||||||
|
static let shared = SettingsManager()
|
||||||
|
|
||||||
|
// MARK: - Published Properties
|
||||||
|
|
||||||
|
@Published private(set) var settings: AppSettings
|
||||||
|
|
||||||
|
// MARK: - Private Properties
|
||||||
|
|
||||||
|
private let settingsKey = "PsytranceVisualizerSettings"
|
||||||
|
private let fileManager = FileManager.default
|
||||||
|
private var saveWorkItem: DispatchWorkItem?
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
self.settings = SettingsManager.loadSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Methods
|
||||||
|
|
||||||
|
/// Updates settings and triggers auto-save
|
||||||
|
func updateSettings(_ update: (inout AppSettings) -> Void) {
|
||||||
|
update(&settings)
|
||||||
|
settings.validate()
|
||||||
|
scheduleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates selected audio device
|
||||||
|
func setAudioDevice(uid: String?) {
|
||||||
|
updateSettings { $0.selectedAudioDeviceUID = uid }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates buffer size
|
||||||
|
func setBufferSize(_ size: Int) {
|
||||||
|
guard AppSettings.availableBufferSizes.contains(size) else { return }
|
||||||
|
updateSettings { $0.bufferSize = size }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates visualization mode
|
||||||
|
func setVisualizationMode(_ mode: VisualizationMode) {
|
||||||
|
updateSettings { $0.lastVisualizationMode = mode.rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates reactivity
|
||||||
|
func setReactivity(_ value: Float) {
|
||||||
|
updateSettings { $0.reactivity = max(0.0, min(1.0, value)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates fullscreen state
|
||||||
|
func setFullscreen(_ isFullscreen: Bool) {
|
||||||
|
updateSettings { $0.isFullscreen = isFullscreen }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates window frame
|
||||||
|
func setWindowFrame(_ frame: CGRect) {
|
||||||
|
updateSettings { $0.windowFrame = CodableRect(from: frame) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates input gain
|
||||||
|
func setInputGain(_ gain: Float) {
|
||||||
|
updateSettings { $0.inputGain = max(0.0, min(2.0, gain)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates FPS display setting
|
||||||
|
func setShowFPS(_ show: Bool) {
|
||||||
|
updateSettings { $0.showFPS = show }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forces immediate save
|
||||||
|
func saveNow() {
|
||||||
|
saveWorkItem?.cancel()
|
||||||
|
performSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resets to default settings
|
||||||
|
func resetToDefaults() {
|
||||||
|
settings = .default
|
||||||
|
saveNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
|
||||||
|
/// Schedules a debounced save operation
|
||||||
|
private func scheduleSave() {
|
||||||
|
saveWorkItem?.cancel()
|
||||||
|
|
||||||
|
let workItem = DispatchWorkItem { [weak self] in
|
||||||
|
self?.performSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
saveWorkItem = workItem
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: workItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs the actual save operation
|
||||||
|
private func performSave() {
|
||||||
|
do {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = .prettyPrinted
|
||||||
|
let data = try encoder.encode(settings)
|
||||||
|
|
||||||
|
// Save to UserDefaults
|
||||||
|
UserDefaults.standard.set(data, forKey: settingsKey)
|
||||||
|
|
||||||
|
// Also save to file for backup
|
||||||
|
if let url = settingsFileURL {
|
||||||
|
try data.write(to: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[SettingsManager] Settings saved successfully")
|
||||||
|
} catch {
|
||||||
|
print("[SettingsManager] Failed to save settings: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads settings from storage
|
||||||
|
private static func loadSettings() -> AppSettings {
|
||||||
|
// Try UserDefaults first
|
||||||
|
if let data = UserDefaults.standard.data(forKey: "PsytranceVisualizerSettings") {
|
||||||
|
do {
|
||||||
|
var settings = try JSONDecoder().decode(AppSettings.self, from: data)
|
||||||
|
settings.validate()
|
||||||
|
print("[SettingsManager] Settings loaded from UserDefaults")
|
||||||
|
return settings
|
||||||
|
} catch {
|
||||||
|
print("[SettingsManager] Failed to decode settings from UserDefaults: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try file backup
|
||||||
|
if let url = settingsFileURL,
|
||||||
|
let data = try? Data(contentsOf: url) {
|
||||||
|
do {
|
||||||
|
var settings = try JSONDecoder().decode(AppSettings.self, from: data)
|
||||||
|
settings.validate()
|
||||||
|
print("[SettingsManager] Settings loaded from file")
|
||||||
|
return settings
|
||||||
|
} catch {
|
||||||
|
print("[SettingsManager] Failed to decode settings from file: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[SettingsManager] Using default settings")
|
||||||
|
return .default
|
||||||
|
}
|
||||||
|
|
||||||
|
/// URL for settings file backup
|
||||||
|
private static var settingsFileURL: URL? {
|
||||||
|
guard let appSupport = FileManager.default.urls(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask
|
||||||
|
).first else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let appDirectory = appSupport.appendingPathComponent("PsytranceVisualizer")
|
||||||
|
|
||||||
|
// Create directory if needed
|
||||||
|
try? FileManager.default.createDirectory(
|
||||||
|
at: appDirectory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
|
||||||
|
return appDirectory.appendingPathComponent("settings.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current visualization mode
|
||||||
|
var currentVisualizationMode: VisualizationMode {
|
||||||
|
VisualizationMode(rawValue: settings.lastVisualizationMode) ?? .fftClassic
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
|
|
||||||
func application(_ application: UIApplication,
|
func application(_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
// Configure for macOS
|
||||||
|
configureMacOS()
|
||||||
|
#endif
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,10 +37,60 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||||
// Resume game if needed
|
// Resume game if needed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
// MARK: - macOS Configuration
|
||||||
|
private func configureMacOS() {
|
||||||
|
// Set minimum window size for macOS
|
||||||
|
UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.forEach { windowScene in
|
||||||
|
windowScene.sizeRestrictions?.minimumSize = CGSize(width: 400, height: 600)
|
||||||
|
windowScene.sizeRestrictions?.maximumSize = CGSize(width: 600, height: 900)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func buildMenu(with builder: UIMenuBuilder) {
|
||||||
|
super.buildMenu(with: builder)
|
||||||
|
|
||||||
|
// Remove unnecessary menus for a game
|
||||||
|
builder.remove(menu: .format)
|
||||||
|
builder.remove(menu: .edit)
|
||||||
|
|
||||||
|
// Add Game menu
|
||||||
|
let pauseCommand = UIKeyCommand(
|
||||||
|
title: "Pause",
|
||||||
|
action: #selector(handlePauseCommand),
|
||||||
|
input: "p",
|
||||||
|
modifierFlags: .command
|
||||||
|
)
|
||||||
|
|
||||||
|
let restartCommand = UIKeyCommand(
|
||||||
|
title: "Neustart",
|
||||||
|
action: #selector(handleRestartCommand),
|
||||||
|
input: "r",
|
||||||
|
modifierFlags: .command
|
||||||
|
)
|
||||||
|
|
||||||
|
let gameMenu = UIMenu(
|
||||||
|
title: "Spiel",
|
||||||
|
children: [pauseCommand, restartCommand]
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.insertSibling(gameMenu, afterMenu: .file)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handlePauseCommand() {
|
||||||
|
NotificationCenter.default.post(name: .pauseGame, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleRestartCommand() {
|
||||||
|
NotificationCenter.default.post(name: .restartGame, object: nil)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Notification Names
|
// MARK: - Notification Names
|
||||||
extension Notification.Name {
|
extension Notification.Name {
|
||||||
static let pauseGame = Notification.Name("pauseGame")
|
static let pauseGame = Notification.Name("pauseGame")
|
||||||
static let resumeGame = Notification.Name("resumeGame")
|
static let resumeGame = Notification.Name("resumeGame")
|
||||||
|
static let restartGame = Notification.Name("restartGame")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ class GameViewController: UIViewController {
|
|||||||
|
|
||||||
// Setup notification observers
|
// Setup notification observers
|
||||||
setupNotificationObservers()
|
setupNotificationObservers()
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
setupMacCatalyst()
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupNotificationObservers() {
|
private func setupNotificationObservers() {
|
||||||
@@ -45,6 +49,13 @@ class GameViewController: UIViewController {
|
|||||||
name: .pauseGame,
|
name: .pauseGame,
|
||||||
object: nil
|
object: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(handleRestartNotification),
|
||||||
|
name: .restartGame,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handlePauseNotification() {
|
@objc private func handlePauseNotification() {
|
||||||
@@ -57,12 +68,46 @@ class GameViewController: UIViewController {
|
|||||||
// This is just a notification that the app is going to background
|
// This is just a notification that the app is going to background
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func handleRestartNotification() {
|
||||||
|
guard let skView = self.view as? SKView else { return }
|
||||||
|
|
||||||
|
let menuScene = MenuScene(size: skView.bounds.size)
|
||||||
|
menuScene.scaleMode = .aspectFill
|
||||||
|
|
||||||
|
let transition = SKTransition.fade(withDuration: 0.5)
|
||||||
|
skView.presentScene(menuScene, transition: transition)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
private func setupMacCatalyst() {
|
||||||
|
// Configure window appearance for macOS
|
||||||
|
if let windowScene = view.window?.windowScene {
|
||||||
|
windowScene.title = "Rollkoffer Simulator"
|
||||||
|
|
||||||
|
// Set window style
|
||||||
|
if let titlebar = windowScene.titlebar {
|
||||||
|
titlebar.titleVisibility = .visible
|
||||||
|
titlebar.toolbarStyle = .unified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable keyboard input
|
||||||
|
override var canBecomeFirstResponder: Bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
return .all
|
||||||
|
#else
|
||||||
if UIDevice.current.userInterfaceIdiom == .phone {
|
if UIDevice.current.userInterfaceIdiom == .phone {
|
||||||
return .portrait
|
return .portrait
|
||||||
} else {
|
} else {
|
||||||
return .all
|
return .all
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
override var prefersStatusBarHidden: Bool {
|
override var prefersStatusBarHidden: Bool {
|
||||||
|
|||||||
@@ -50,5 +50,9 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>13.0</string>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>Copyright 2024 Ingo K. All rights reserved.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -381,6 +381,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Info.plist;
|
INFOPLIST_FILE = Info.plist;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
@@ -393,9 +394,12 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.ingok.RollkofferSimulator;
|
PRODUCT_BUNDLE_IDENTIFIER = com.ingok.RollkofferSimulator;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SUPPORTS_MACCATALYST = YES;
|
||||||
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
@@ -409,6 +413,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Info.plist;
|
INFOPLIST_FILE = Info.plist;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
@@ -421,9 +426,12 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.ingok.RollkofferSimulator;
|
PRODUCT_BUNDLE_IDENTIFIER = com.ingok.RollkofferSimulator;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SUPPORTS_MACCATALYST = YES;
|
||||||
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
|||||||
@@ -223,6 +223,27 @@ class GameOverScene: SKScene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Keyboard Handling (macOS)
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
override var canBecomeFirstResponder: Bool { true }
|
||||||
|
|
||||||
|
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
|
||||||
|
guard let key = presses.first?.key else {
|
||||||
|
super.pressesBegan(presses, with: event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch key.keyCode {
|
||||||
|
case .keyboardSpacebar, .keyboardReturnOrEnter:
|
||||||
|
retryGame()
|
||||||
|
case .keyboardEscape:
|
||||||
|
returnToMenu()
|
||||||
|
default:
|
||||||
|
super.pressesBegan(presses, with: event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
private func retryGame() {
|
private func retryGame() {
|
||||||
let pressDown = SKAction.scale(to: 0.9, duration: 0.1)
|
let pressDown = SKAction.scale(to: 0.9, duration: 0.1)
|
||||||
let pressUp = SKAction.scale(to: 1.0, duration: 0.1)
|
let pressUp = SKAction.scale(to: 1.0, duration: 0.1)
|
||||||
|
|||||||
@@ -318,6 +318,29 @@ class GameScene: SKScene {
|
|||||||
isDragging = false
|
isDragging = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Keyboard Handling (macOS)
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
override var canBecomeFirstResponder: Bool { true }
|
||||||
|
|
||||||
|
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
|
||||||
|
guard let key = presses.first?.key else {
|
||||||
|
super.pressesBegan(presses, with: event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch key.keyCode {
|
||||||
|
case .keyboardEscape:
|
||||||
|
togglePause()
|
||||||
|
case .keyboardSpacebar:
|
||||||
|
if gameState.currentState == .paused {
|
||||||
|
resumeGame()
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
super.pressesBegan(presses, with: event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// MARK: - Pause Handling
|
// MARK: - Pause Handling
|
||||||
private func togglePause() {
|
private func togglePause() {
|
||||||
if gameState.currentState == .playing {
|
if gameState.currentState == .playing {
|
||||||
|
|||||||
@@ -245,6 +245,25 @@ class MenuScene: SKScene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Keyboard Handling (macOS)
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
override var canBecomeFirstResponder: Bool { true }
|
||||||
|
|
||||||
|
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
|
||||||
|
guard let key = presses.first?.key else {
|
||||||
|
super.pressesBegan(presses, with: event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch key.keyCode {
|
||||||
|
case .keyboardSpacebar, .keyboardReturnOrEnter:
|
||||||
|
startGame()
|
||||||
|
default:
|
||||||
|
super.pressesBegan(presses, with: event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
private func startGame() {
|
private func startGame() {
|
||||||
// Button press effect
|
// Button press effect
|
||||||
let pressDown = SKAction.scale(to: 0.9, duration: 0.1)
|
let pressDown = SKAction.scale(to: 0.9, duration: 0.1)
|
||||||
|
|||||||
@@ -280,6 +280,27 @@ class VictoryScene: SKScene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Keyboard Handling (macOS)
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
override var canBecomeFirstResponder: Bool { true }
|
||||||
|
|
||||||
|
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
|
||||||
|
guard let key = presses.first?.key else {
|
||||||
|
super.pressesBegan(presses, with: event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch key.keyCode {
|
||||||
|
case .keyboardSpacebar, .keyboardReturnOrEnter:
|
||||||
|
playAgain()
|
||||||
|
case .keyboardEscape:
|
||||||
|
returnToMenu()
|
||||||
|
default:
|
||||||
|
super.pressesBegan(presses, with: event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
private func playAgain() {
|
private func playAgain() {
|
||||||
let pressDown = SKAction.scale(to: 0.9, duration: 0.1)
|
let pressDown = SKAction.scale(to: 0.9, duration: 0.1)
|
||||||
let pressUp = SKAction.scale(to: 1.0, duration: 0.1)
|
let pressUp = SKAction.scale(to: 1.0, duration: 0.1)
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Build artifacts
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
verify_probe_out*/
|
||||||
|
build_verify/
|
||||||
|
output/
|
||||||
|
|
||||||
|
# Visual Studio user/IDE files
|
||||||
|
.vs/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
|
||||||
|
# Local diagnostics and scratch artifacts
|
||||||
|
.config/
|
||||||
|
.tmp_tools/
|
||||||
|
Tools/FinanceProbe/.tmp_tools/
|
||||||
|
Tools/FinanceProbe/verify_probe_out*/
|
||||||
|
*.out.log
|
||||||
|
*.err.log
|
||||||
|
mainapp*.log
|
||||||
|
financeprobe*.log
|
||||||
|
netsh
|
||||||
|
11.15.0
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$exe = Join-Path $PSScriptRoot 'bin\x86\Release\net48\SapProbe.exe'
|
||||||
|
$log = Join-Path $PSScriptRoot 'sap_probe_last_run.log'
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $exe)) {
|
||||||
|
Write-Host "SapProbe.exe was not found:"
|
||||||
|
Write-Host $exe
|
||||||
|
Read-Host "Press Enter to close"
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Test-Path -LiteralPath $log) {
|
||||||
|
Remove-Item -LiteralPath $log -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
Start-Transcript -Path $log -Force | Out-Null
|
||||||
|
try {
|
||||||
|
& $exe @args
|
||||||
|
$exitCode = $LASTEXITCODE
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host "Exit code: $exitCode"
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Stop-Transcript | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Test-Path -LiteralPath $log) {
|
||||||
|
$content = Get-Content -LiteralPath $log -Raw
|
||||||
|
$content = [regex]::Replace($content, '(?m)^Password for .*$','Password prompt: [masked input omitted]')
|
||||||
|
Set-Content -LiteralPath $log -Value $content -Encoding UTF8
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host "Log file: $log"
|
||||||
|
Read-Host "Press Enter to close"
|
||||||
|
exit $exitCode
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net48</TargetFramework>
|
||||||
|
<PlatformTarget>x86</PlatformTarget>
|
||||||
|
<Prefer32Bit>true</Prefer32Bit>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Nullable>disable</Nullable>
|
||||||
|
<AssemblyName>SapProbe</AssemblyName>
|
||||||
|
<RootNamespace>SapProbe</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="sapnco">
|
||||||
|
<HintPath>C:\Windows\Microsoft.NET\assembly\GAC_32\sapnco\v4.0_3.1.0.42__50436dca5c7f7d23\sapnco.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="sapnco_utils">
|
||||||
|
<HintPath>C:\Windows\Microsoft.NET\assembly\GAC_32\sapnco_utils\v4.0_3.1.0.42__50436dca5c7f7d23\sapnco_utils.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
**********************
|
||||||
|
nStart der Windows PowerShell-Aufzeichnung
|
||||||
|
Startzeit: 20260427082528
|
||||||
|
Benutzername: TRAFAGCH\koi
|
||||||
|
RunAs-Benutzer: TRAFAGCH\koi
|
||||||
|
Konfigurationsname:
|
||||||
|
Computer: NB61258 (Microsoft Windows NT 10.0.26200.0)
|
||||||
|
Hostanwendung: C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Users\koi\source\repos\Ai\TrafagSalesExporter\.tmp_sap_probe\RunSapProbeInteractive.ps1 abap-activate Z_TEST3 --dry-run
|
||||||
|
Prozess-ID: 452
|
||||||
|
PSVersion: 5.1.26100.8115
|
||||||
|
PSEdition: Desktop
|
||||||
|
PSCompatibleVersions: 1.0, 2.0, 3.0, 4.0, 5.0, 5.1.26100.8115
|
||||||
|
BuildVersion: 10.0.26100.8115
|
||||||
|
CLRVersion: 4.0.30319.42000
|
||||||
|
WSManStackVersion: 3.0
|
||||||
|
PSRemotingProtocolVersion: 2.3
|
||||||
|
SerializationVersion: 1.1.0.1
|
||||||
|
**********************
|
||||||
|
SAP NCo CLI
|
||||||
|
Architecture : x86
|
||||||
|
NCo Assembly : sapnco, Version=3.1.0.42, Culture=neutral, PublicKeyToken=50436dca5c7f7d23
|
||||||
|
Password prompt: [masked input omitted]
|
||||||
|
Target : travt762.sap.trafag.com / SYSNR 00 / CLIENT 100 / USER KOI
|
||||||
|
Ping : OK
|
||||||
|
|
||||||
|
Program : Z_TEST3
|
||||||
|
Lines : 69
|
||||||
|
Activation : RPY_PROGRAM_INSERT with SAVE_INACTIVE blank
|
||||||
|
Dry run : no SAP repository changes were written.
|
||||||
|
|
||||||
|
Exit code: 0
|
||||||
|
**********************
|
||||||
|
Ende der Windows PowerShell-Aufzeichnung
|
||||||
|
Endzeit: 20260427082529
|
||||||
|
**********************
|
||||||
|
|
||||||
Binary file not shown.
@@ -0,0 +1,136 @@
|
|||||||
|
Alphaplan SQL Discovery Exporter
|
||||||
|
================================
|
||||||
|
|
||||||
|
Purpose
|
||||||
|
-------
|
||||||
|
|
||||||
|
Run this package on the German Alphaplan SQL Server machine.
|
||||||
|
|
||||||
|
It performs Phase 1 discovery only:
|
||||||
|
|
||||||
|
- scan accessible SQL Server databases
|
||||||
|
- identify tables/views that look relevant for finance, invoices, sales, customers, articles and amounts
|
||||||
|
- write candidate_objects.csv
|
||||||
|
- write export_summary.csv
|
||||||
|
- optionally write small sample_*.csv files
|
||||||
|
- optionally upload the run folder to SharePoint with rclone
|
||||||
|
|
||||||
|
The script only reads SQL Server metadata/data. It does not change Alphaplan, SQL Server or BiDashboard.
|
||||||
|
|
||||||
|
|
||||||
|
Default SharePoint target
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
The default rclone target is:
|
||||||
|
|
||||||
|
trafag-bi:Import/Finance/Deutschland/AlphaplanRaw
|
||||||
|
|
||||||
|
Use this raw folder first so the existing Germany import is not disturbed.
|
||||||
|
|
||||||
|
|
||||||
|
Typical commands
|
||||||
|
----------------
|
||||||
|
|
||||||
|
Open PowerShell on the Alphaplan server in this package folder.
|
||||||
|
|
||||||
|
Allow script execution for this PowerShell window:
|
||||||
|
|
||||||
|
Set-ExecutionPolicy -Scope Process Bypass
|
||||||
|
|
||||||
|
Run discovery and upload with defaults:
|
||||||
|
|
||||||
|
.\Run-AlphaplanDiscoveryAndUpload.ps1
|
||||||
|
|
||||||
|
Run discovery for one known database:
|
||||||
|
|
||||||
|
.\Run-AlphaplanDiscoveryAndUpload.ps1 -Database "ALPHAPLAN"
|
||||||
|
|
||||||
|
Run discovery without SharePoint upload:
|
||||||
|
|
||||||
|
.\Run-AlphaplanDiscoveryAndUpload.ps1 -SkipUpload
|
||||||
|
|
||||||
|
Run discovery and include small samples from top candidate tables/views:
|
||||||
|
|
||||||
|
.\Run-AlphaplanDiscoveryAndUpload.ps1 -Database "ALPHAPLAN" -ExportSamples
|
||||||
|
|
||||||
|
Use another SQL Server instance:
|
||||||
|
|
||||||
|
.\Run-AlphaplanDiscoveryAndUpload.ps1 -ServerInstance "SERVERNAME\INSTANCE" -Database "ALPHAPLAN"
|
||||||
|
|
||||||
|
Use SQL authentication:
|
||||||
|
|
||||||
|
$cred = Get-Credential
|
||||||
|
.\Run-AlphaplanDiscoveryAndUpload.ps1 -ServerInstance "SERVERNAME\INSTANCE" -Database "ALPHAPLAN" -SqlCredential $cred
|
||||||
|
|
||||||
|
Use another rclone remote name:
|
||||||
|
|
||||||
|
.\Run-AlphaplanDiscoveryAndUpload.ps1 -RcloneRemote "YOUR_REMOTE"
|
||||||
|
|
||||||
|
Use another rclone executable:
|
||||||
|
|
||||||
|
.\Run-AlphaplanDiscoveryAndUpload.ps1 -RcloneExe "C:\Tools\rclone\rclone.exe"
|
||||||
|
|
||||||
|
|
||||||
|
Output
|
||||||
|
------
|
||||||
|
|
||||||
|
Default local folder:
|
||||||
|
|
||||||
|
C:\Trafag\AlphaplanExport\out\Alphaplan_SQL_Discovery_YYYYMMDD_HHMMSS
|
||||||
|
|
||||||
|
Main files:
|
||||||
|
|
||||||
|
- candidate_objects.csv
|
||||||
|
- export_summary.csv
|
||||||
|
- README.txt
|
||||||
|
- sample_*.csv when -ExportSamples is used
|
||||||
|
|
||||||
|
|
||||||
|
rclone prerequisites
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
rclone must already be configured on the Alphaplan server.
|
||||||
|
|
||||||
|
Expected remote:
|
||||||
|
|
||||||
|
trafag-bi
|
||||||
|
|
||||||
|
The remote should point to the "Shared Documents" document library of:
|
||||||
|
|
||||||
|
https://trafagag.sharepoint.com/sites/WorldwideBIPlatform
|
||||||
|
|
||||||
|
Quick rclone checks:
|
||||||
|
|
||||||
|
rclone lsd trafag-bi:
|
||||||
|
rclone lsd trafag-bi:"Import/Finance"
|
||||||
|
rclone lsd trafag-bi:"Import/Finance/Deutschland"
|
||||||
|
|
||||||
|
|
||||||
|
Recommended Phase 1 workflow
|
||||||
|
----------------------------
|
||||||
|
|
||||||
|
1. Run discovery without samples:
|
||||||
|
|
||||||
|
.\Run-AlphaplanDiscoveryAndUpload.ps1 -SkipUpload
|
||||||
|
|
||||||
|
2. Check candidate_objects.csv locally.
|
||||||
|
|
||||||
|
3. If the result looks plausible, run with upload:
|
||||||
|
|
||||||
|
.\Run-AlphaplanDiscoveryAndUpload.ps1
|
||||||
|
|
||||||
|
4. If Andreas/DE IT needs examples, run samples:
|
||||||
|
|
||||||
|
.\Run-AlphaplanDiscoveryAndUpload.ps1 -ExportSamples
|
||||||
|
|
||||||
|
5. Use candidate_objects.csv to identify the correct invoice header, invoice line, customer, article and credit note/storno objects.
|
||||||
|
|
||||||
|
|
||||||
|
Notes
|
||||||
|
-----
|
||||||
|
|
||||||
|
- If -Database is empty, all accessible user databases are scanned.
|
||||||
|
- If the SQL user has limited permissions, candidate_objects.csv may be empty or incomplete.
|
||||||
|
- Use a read-only SQL user or Windows account.
|
||||||
|
- For Phase 1, no BiDashboard import mapping is required.
|
||||||
|
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
param(
|
||||||
|
[string]$ServerInstance = "localhost",
|
||||||
|
[string]$Database = "",
|
||||||
|
[System.Management.Automation.PSCredential]$SqlCredential,
|
||||||
|
[string]$BaseDirectory = "C:\Trafag\AlphaplanExport",
|
||||||
|
[int]$MaxCandidatesPerDatabase = 80,
|
||||||
|
[switch]$ExportSamples,
|
||||||
|
[int]$MaxSampleObjects = 15,
|
||||||
|
[int]$SampleRows = 200,
|
||||||
|
[switch]$IncludeSystemDatabases,
|
||||||
|
[switch]$SkipUpload,
|
||||||
|
[string]$RcloneExe = "C:\Tools\rclone.exe",
|
||||||
|
[string]$RcloneRemote = "trafag-bi",
|
||||||
|
[string]$RcloneTarget = "Import/Finance/Deutschland/AlphaplanRaw"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function New-Connection {
|
||||||
|
param([string]$DbName)
|
||||||
|
|
||||||
|
$builder = New-Object System.Data.SqlClient.SqlConnectionStringBuilder
|
||||||
|
$builder["Data Source"] = $ServerInstance
|
||||||
|
$builder["Initial Catalog"] = $DbName
|
||||||
|
$builder["TrustServerCertificate"] = $true
|
||||||
|
$builder["Connect Timeout"] = 15
|
||||||
|
|
||||||
|
if ($null -ne $SqlCredential) {
|
||||||
|
$builder["Integrated Security"] = $false
|
||||||
|
$builder["User ID"] = $SqlCredential.UserName
|
||||||
|
$builder["Password"] = $SqlCredential.GetNetworkCredential().Password
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$builder["Integrated Security"] = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
return New-Object System.Data.SqlClient.SqlConnection($builder.ConnectionString)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Add-SqlParameter {
|
||||||
|
param(
|
||||||
|
[System.Data.SqlClient.SqlCommand]$Command,
|
||||||
|
[string]$Name,
|
||||||
|
$Value
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($Value -is [int]) {
|
||||||
|
$parameter = $Command.Parameters.Add("@$Name", [System.Data.SqlDbType]::Int)
|
||||||
|
$parameter.Value = $Value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Value -is [datetime]) {
|
||||||
|
$parameter = $Command.Parameters.Add("@$Name", [System.Data.SqlDbType]::DateTime)
|
||||||
|
$parameter.Value = $Value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$textParameter = $Command.Parameters.Add("@$Name", [System.Data.SqlDbType]::NVarChar, 4000)
|
||||||
|
if ($null -eq $Value) {
|
||||||
|
$textParameter.Value = [System.DBNull]::Value
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$textParameter.Value = [string]$Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-DataTable {
|
||||||
|
param(
|
||||||
|
[string]$DbName,
|
||||||
|
[string]$Sql,
|
||||||
|
[hashtable]$Parameters = @{}
|
||||||
|
)
|
||||||
|
|
||||||
|
$conn = New-Connection $DbName
|
||||||
|
$cmd = $conn.CreateCommand()
|
||||||
|
$cmd.CommandText = $Sql
|
||||||
|
$cmd.CommandTimeout = 300
|
||||||
|
|
||||||
|
foreach ($key in $Parameters.Keys) {
|
||||||
|
Add-SqlParameter -Command $cmd -Name $key -Value $Parameters[$key]
|
||||||
|
}
|
||||||
|
|
||||||
|
$table = New-Object System.Data.DataTable
|
||||||
|
try {
|
||||||
|
$conn.Open()
|
||||||
|
$reader = $cmd.ExecuteReader()
|
||||||
|
$table.Load($reader)
|
||||||
|
$reader.Dispose()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$cmd.Dispose()
|
||||||
|
$conn.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return $table
|
||||||
|
}
|
||||||
|
|
||||||
|
function Convert-DataRowToObject {
|
||||||
|
param([System.Data.DataRow]$Row)
|
||||||
|
|
||||||
|
$props = [ordered]@{}
|
||||||
|
foreach ($column in $Row.Table.Columns) {
|
||||||
|
$value = $Row[$column.ColumnName]
|
||||||
|
if ($null -eq $value -or $value -is [System.DBNull]) {
|
||||||
|
$props[$column.ColumnName] = ""
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$props[$column.ColumnName] = $value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [pscustomobject]$props
|
||||||
|
}
|
||||||
|
|
||||||
|
function Convert-ToCsvValue {
|
||||||
|
param($Value)
|
||||||
|
|
||||||
|
if ($null -eq $Value -or $Value -is [System.DBNull]) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Value -is [datetime]) {
|
||||||
|
$text = $Value.ToString("yyyy-MM-dd HH:mm:ss")
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$text = [string]$Value
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = $text.Replace('"', '""')
|
||||||
|
return '"' + $text + '"'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Export-QueryToCsv {
|
||||||
|
param(
|
||||||
|
[string]$DbName,
|
||||||
|
[string]$Sql,
|
||||||
|
[string]$Path
|
||||||
|
)
|
||||||
|
|
||||||
|
$conn = New-Connection $DbName
|
||||||
|
$cmd = $conn.CreateCommand()
|
||||||
|
$cmd.CommandText = $Sql
|
||||||
|
$cmd.CommandTimeout = 0
|
||||||
|
|
||||||
|
$writer = New-Object System.IO.StreamWriter($Path, $false, [System.Text.Encoding]::UTF8)
|
||||||
|
$rowCount = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
$conn.Open()
|
||||||
|
$reader = $cmd.ExecuteReader()
|
||||||
|
|
||||||
|
$headers = for ($i = 0; $i -lt $reader.FieldCount; $i++) {
|
||||||
|
Convert-ToCsvValue $reader.GetName($i)
|
||||||
|
}
|
||||||
|
$writer.WriteLine(($headers -join ";"))
|
||||||
|
|
||||||
|
while ($reader.Read()) {
|
||||||
|
$values = for ($i = 0; $i -lt $reader.FieldCount; $i++) {
|
||||||
|
Convert-ToCsvValue $reader.GetValue($i)
|
||||||
|
}
|
||||||
|
$writer.WriteLine(($values -join ";"))
|
||||||
|
$rowCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
$reader.Dispose()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$writer.Dispose()
|
||||||
|
$cmd.Dispose()
|
||||||
|
$conn.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rowCount
|
||||||
|
}
|
||||||
|
|
||||||
|
function Quote-NamePart {
|
||||||
|
param([string]$Name)
|
||||||
|
|
||||||
|
return "[" + $Name.Replace("]", "]]") + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Normalize-FileName {
|
||||||
|
param([string]$Value)
|
||||||
|
|
||||||
|
$safe = ($Value -replace '[\\/:*?"<>|]', '_')
|
||||||
|
if ($safe.Length -gt 150) {
|
||||||
|
return $safe.Substring(0, 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
return $safe
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-UserDatabases {
|
||||||
|
$systemFilter = if ($IncludeSystemDatabases) { "" } else { "AND database_id > 4" }
|
||||||
|
$sql = @"
|
||||||
|
SELECT name
|
||||||
|
FROM sys.databases
|
||||||
|
WHERE state_desc = 'ONLINE'
|
||||||
|
AND HAS_DBACCESS(name) = 1
|
||||||
|
$systemFilter
|
||||||
|
ORDER BY name;
|
||||||
|
"@
|
||||||
|
|
||||||
|
$table = Invoke-DataTable "master" $sql
|
||||||
|
$result = New-Object System.Collections.Generic.List[string]
|
||||||
|
foreach ($row in $table.Rows) {
|
||||||
|
$result.Add([string]$row["name"])
|
||||||
|
}
|
||||||
|
|
||||||
|
return @($result)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-CandidateObjects {
|
||||||
|
param([string]$DbName)
|
||||||
|
|
||||||
|
$sql = @"
|
||||||
|
WITH object_columns AS (
|
||||||
|
SELECT
|
||||||
|
o.object_id AS ObjectId,
|
||||||
|
s.name AS SchemaName,
|
||||||
|
o.name AS ObjectName,
|
||||||
|
o.type_desc AS ObjectType,
|
||||||
|
c.name AS ColumnName,
|
||||||
|
t.name AS TypeName,
|
||||||
|
c.column_id AS ColumnId
|
||||||
|
FROM sys.objects o
|
||||||
|
JOIN sys.schemas s ON s.schema_id = o.schema_id
|
||||||
|
JOIN sys.columns c ON c.object_id = o.object_id
|
||||||
|
JOIN sys.types t ON t.user_type_id = c.user_type_id
|
||||||
|
WHERE o.type IN ('U', 'V')
|
||||||
|
AND o.is_ms_shipped = 0
|
||||||
|
),
|
||||||
|
scored AS (
|
||||||
|
SELECT
|
||||||
|
ObjectId,
|
||||||
|
SchemaName,
|
||||||
|
ObjectName,
|
||||||
|
ObjectType,
|
||||||
|
COUNT(*) AS ColumnCount,
|
||||||
|
SUM(
|
||||||
|
CASE WHEN LOWER(ObjectName) LIKE '%rechnung%' OR LOWER(ObjectName) LIKE '%fakt%' OR LOWER(ObjectName) LIKE '%invoice%' OR LOWER(ObjectName) LIKE '%fact%' OR LOWER(ObjectName) LIKE '%beleg%' THEN 8 ELSE 0 END +
|
||||||
|
CASE WHEN LOWER(ObjectName) LIKE '%umsatz%' OR LOWER(ObjectName) LIKE '%verkauf%' OR LOWER(ObjectName) LIKE '%sales%' OR LOWER(ObjectName) LIKE '%revenue%' THEN 6 ELSE 0 END +
|
||||||
|
CASE WHEN LOWER(ObjectName) LIKE '%position%' OR LOWER(ObjectName) LIKE '%zeile%' OR LOWER(ObjectName) LIKE '%line%' THEN 4 ELSE 0 END +
|
||||||
|
CASE WHEN LOWER(ObjectName) LIKE '%auftrag%' OR LOWER(ObjectName) LIKE '%order%' THEN 4 ELSE 0 END +
|
||||||
|
CASE WHEN LOWER(ObjectName) LIKE '%artikel%' OR LOWER(ObjectName) LIKE '%material%' OR LOWER(ObjectName) LIKE '%item%' OR LOWER(ObjectName) LIKE '%produkt%' THEN 5 ELSE 0 END +
|
||||||
|
CASE WHEN LOWER(ObjectName) LIKE '%kunde%' OR LOWER(ObjectName) LIKE '%debitor%' OR LOWER(ObjectName) LIKE '%customer%' OR LOWER(ObjectName) LIKE '%adresse%' THEN 4 ELSE 0 END +
|
||||||
|
CASE WHEN LOWER(ObjectName) LIKE '%gutschrift%' OR LOWER(ObjectName) LIKE '%storno%' OR LOWER(ObjectName) LIKE '%credit%' THEN 4 ELSE 0 END +
|
||||||
|
CASE WHEN LOWER(ColumnName) LIKE '%datum%' OR LOWER(ColumnName) LIKE '%date%' OR LOWER(ColumnName) LIKE '%zeit%' THEN 2 ELSE 0 END +
|
||||||
|
CASE WHEN LOWER(ColumnName) LIKE '%rechnung%' OR LOWER(ColumnName) LIKE '%fakt%' OR LOWER(ColumnName) LIKE '%invoice%' OR LOWER(ColumnName) LIKE '%beleg%' THEN 2 ELSE 0 END +
|
||||||
|
CASE WHEN LOWER(ColumnName) LIKE '%kunde%' OR LOWER(ColumnName) LIKE '%debitor%' OR LOWER(ColumnName) LIKE '%customer%' OR LOWER(ColumnName) LIKE '%adresse%' THEN 2 ELSE 0 END +
|
||||||
|
CASE WHEN LOWER(ColumnName) LIKE '%artikel%' OR LOWER(ColumnName) LIKE '%material%' OR LOWER(ColumnName) LIKE '%item%' OR LOWER(ColumnName) LIKE '%produkt%' OR LOWER(ColumnName) LIKE '%artnr%' THEN 2 ELSE 0 END +
|
||||||
|
CASE WHEN LOWER(ColumnName) LIKE '%betrag%' OR LOWER(ColumnName) LIKE '%netto%' OR LOWER(ColumnName) LIKE '%umsatz%' OR LOWER(ColumnName) LIKE '%amount%' OR LOWER(ColumnName) LIKE '%price%' OR LOWER(ColumnName) LIKE '%preis%' OR LOWER(ColumnName) LIKE '%summe%' THEN 3 ELSE 0 END +
|
||||||
|
CASE WHEN LOWER(ColumnName) LIKE '%menge%' OR LOWER(ColumnName) LIKE '%anzahl%' OR LOWER(ColumnName) LIKE '%quantity%' OR LOWER(ColumnName) LIKE '%qty%' THEN 2 ELSE 0 END +
|
||||||
|
CASE WHEN LOWER(ColumnName) LIKE '%waehr%' OR LOWER(ColumnName) LIKE '%currency%' OR LOWER(ColumnName) LIKE '%whrg%' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN LOWER(ColumnName) LIKE '%warengruppe%' OR LOWER(ColumnName) LIKE '%produktgruppe%' OR LOWER(ColumnName) LIKE '%productgroup%' THEN 1 ELSE 0 END
|
||||||
|
) AS Score
|
||||||
|
FROM object_columns
|
||||||
|
GROUP BY ObjectId, SchemaName, ObjectName, ObjectType
|
||||||
|
)
|
||||||
|
SELECT TOP (@MaxCandidates)
|
||||||
|
DB_NAME() AS DatabaseName,
|
||||||
|
s.SchemaName,
|
||||||
|
s.ObjectName,
|
||||||
|
s.ObjectType,
|
||||||
|
s.Score,
|
||||||
|
s.ColumnCount,
|
||||||
|
ISNULL((
|
||||||
|
SELECT SUM(p.row_count)
|
||||||
|
FROM sys.dm_db_partition_stats p
|
||||||
|
WHERE p.object_id = s.ObjectId
|
||||||
|
AND p.index_id IN (0, 1)
|
||||||
|
), 0) AS RowCountEstimate,
|
||||||
|
STUFF((
|
||||||
|
SELECT ', ' + oc.ColumnName
|
||||||
|
FROM object_columns oc
|
||||||
|
WHERE oc.ObjectId = s.ObjectId
|
||||||
|
AND (
|
||||||
|
oc.TypeName IN ('date', 'datetime', 'datetime2', 'smalldatetime')
|
||||||
|
OR LOWER(oc.ColumnName) LIKE '%datum%'
|
||||||
|
OR LOWER(oc.ColumnName) LIKE '%date%'
|
||||||
|
OR LOWER(oc.ColumnName) LIKE '%zeit%'
|
||||||
|
)
|
||||||
|
ORDER BY oc.ColumnId
|
||||||
|
FOR XML PATH(''), TYPE
|
||||||
|
).value('.', 'nvarchar(max)'), 1, 2, '') AS DateColumnCandidates,
|
||||||
|
STUFF((
|
||||||
|
SELECT ', ' + oc.ColumnName
|
||||||
|
FROM object_columns oc
|
||||||
|
WHERE oc.ObjectId = s.ObjectId
|
||||||
|
AND (
|
||||||
|
LOWER(oc.ColumnName) LIKE '%betrag%'
|
||||||
|
OR LOWER(oc.ColumnName) LIKE '%netto%'
|
||||||
|
OR LOWER(oc.ColumnName) LIKE '%umsatz%'
|
||||||
|
OR LOWER(oc.ColumnName) LIKE '%amount%'
|
||||||
|
OR LOWER(oc.ColumnName) LIKE '%price%'
|
||||||
|
OR LOWER(oc.ColumnName) LIKE '%preis%'
|
||||||
|
OR LOWER(oc.ColumnName) LIKE '%summe%'
|
||||||
|
)
|
||||||
|
ORDER BY oc.ColumnId
|
||||||
|
FOR XML PATH(''), TYPE
|
||||||
|
).value('.', 'nvarchar(max)'), 1, 2, '') AS AmountColumnCandidates,
|
||||||
|
STUFF((
|
||||||
|
SELECT ', ' + oc.ColumnName
|
||||||
|
FROM object_columns oc
|
||||||
|
WHERE oc.ObjectId = s.ObjectId
|
||||||
|
AND (
|
||||||
|
LOWER(oc.ColumnName) LIKE '%rechnung%'
|
||||||
|
OR LOWER(oc.ColumnName) LIKE '%fakt%'
|
||||||
|
OR LOWER(oc.ColumnName) LIKE '%beleg%'
|
||||||
|
OR LOWER(oc.ColumnName) LIKE '%kunde%'
|
||||||
|
OR LOWER(oc.ColumnName) LIKE '%debitor%'
|
||||||
|
OR LOWER(oc.ColumnName) LIKE '%artikel%'
|
||||||
|
OR LOWER(oc.ColumnName) LIKE '%material%'
|
||||||
|
OR LOWER(oc.ColumnName) LIKE '%position%'
|
||||||
|
)
|
||||||
|
ORDER BY oc.ColumnId
|
||||||
|
FOR XML PATH(''), TYPE
|
||||||
|
).value('.', 'nvarchar(max)'), 1, 2, '') AS KeyColumnCandidates,
|
||||||
|
STUFF((
|
||||||
|
SELECT ', ' + oc.ColumnName
|
||||||
|
FROM object_columns oc
|
||||||
|
WHERE oc.ObjectId = s.ObjectId
|
||||||
|
ORDER BY oc.ColumnId
|
||||||
|
FOR XML PATH(''), TYPE
|
||||||
|
).value('.', 'nvarchar(max)'), 1, 2, '') AS Columns
|
||||||
|
FROM scored s
|
||||||
|
WHERE s.Score > 0
|
||||||
|
ORDER BY s.Score DESC, RowCountEstimate DESC, s.ObjectName;
|
||||||
|
"@
|
||||||
|
|
||||||
|
$table = Invoke-DataTable $DbName $sql @{ MaxCandidates = $MaxCandidatesPerDatabase }
|
||||||
|
$result = New-Object System.Collections.Generic.List[object]
|
||||||
|
foreach ($row in $table.Rows) {
|
||||||
|
$result.Add((Convert-DataRowToObject $row))
|
||||||
|
}
|
||||||
|
|
||||||
|
return @($result)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Build-SampleSql {
|
||||||
|
param(
|
||||||
|
[string]$SchemaName,
|
||||||
|
[string]$ObjectName,
|
||||||
|
[int]$Rows
|
||||||
|
)
|
||||||
|
|
||||||
|
$safeRows = [Math]::Max(1, $Rows)
|
||||||
|
$qualifiedName = "$(Quote-NamePart $SchemaName).$(Quote-NamePart $ObjectName)"
|
||||||
|
return "SELECT TOP ($safeRows) * FROM $qualifiedName;"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-RcloneExecutable {
|
||||||
|
param([string]$ConfiguredPath)
|
||||||
|
|
||||||
|
$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$candidates = @(
|
||||||
|
$ConfiguredPath,
|
||||||
|
(Join-Path $scriptDirectory "rclone.exe"),
|
||||||
|
"C:\Tools\rclone.exe",
|
||||||
|
"C:\Tools\rclone\rclone.exe",
|
||||||
|
"C:\Tools\rclone\rclone\rclone.exe",
|
||||||
|
"rclone"
|
||||||
|
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
|
||||||
|
|
||||||
|
foreach ($candidate in $candidates) {
|
||||||
|
if (Test-Path -LiteralPath $candidate) {
|
||||||
|
return (Resolve-Path -LiteralPath $candidate).Path
|
||||||
|
}
|
||||||
|
|
||||||
|
$command = Get-Command $candidate -ErrorAction SilentlyContinue
|
||||||
|
if ($null -ne $command) {
|
||||||
|
return $command.Source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw "rclone executable not found. Checked: $($candidates -join ', ')"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Throw-RcloneError {
|
||||||
|
param(
|
||||||
|
[string]$Message,
|
||||||
|
[string]$LogPath
|
||||||
|
)
|
||||||
|
|
||||||
|
if (Test-Path -LiteralPath $LogPath) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Last rclone log lines:"
|
||||||
|
Get-Content -LiteralPath $LogPath -Tail 80 | ForEach-Object { Write-Host $_ }
|
||||||
|
}
|
||||||
|
|
||||||
|
throw "$Message Log: $LogPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
|
||||||
|
$outputDirectory = Join-Path $BaseDirectory "out"
|
||||||
|
$logDirectory = Join-Path $BaseDirectory "logs"
|
||||||
|
New-Item -ItemType Directory -Force -Path $outputDirectory, $logDirectory | Out-Null
|
||||||
|
|
||||||
|
$runDirectory = Join-Path $outputDirectory "Alphaplan_SQL_Discovery_$timestamp"
|
||||||
|
New-Item -ItemType Directory -Path $runDirectory -Force | Out-Null
|
||||||
|
|
||||||
|
$summary = New-Object System.Collections.Generic.List[object]
|
||||||
|
$allCandidates = New-Object System.Collections.Generic.List[object]
|
||||||
|
|
||||||
|
Write-Host "Alphaplan SQL discovery"
|
||||||
|
Write-Host "Server instance: $ServerInstance"
|
||||||
|
Write-Host "Database filter: $(if ([string]::IsNullOrWhiteSpace($Database)) { '(all accessible user databases)' } else { $Database })"
|
||||||
|
Write-Host "Run directory: $runDirectory"
|
||||||
|
|
||||||
|
$databases = if ([string]::IsNullOrWhiteSpace($Database)) {
|
||||||
|
@(Get-UserDatabases)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
@($Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($databases.Count -eq 0) {
|
||||||
|
throw "No accessible databases found."
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($db in $databases) {
|
||||||
|
Write-Host "Scanning database: $db"
|
||||||
|
|
||||||
|
try {
|
||||||
|
$candidates = @(Get-CandidateObjects $db)
|
||||||
|
foreach ($candidate in $candidates) {
|
||||||
|
$allCandidates.Add($candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary.Add([pscustomobject]@{
|
||||||
|
Created = (Get-Date -Format "yyyy-MM-dd HH:mm:ss")
|
||||||
|
Database = $db
|
||||||
|
Object = ""
|
||||||
|
Action = "Discovery completed"
|
||||||
|
Rows = $candidates.Count
|
||||||
|
File = "candidate_objects.csv"
|
||||||
|
Error = ""
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$summary.Add([pscustomobject]@{
|
||||||
|
Created = (Get-Date -Format "yyyy-MM-dd HH:mm:ss")
|
||||||
|
Database = $db
|
||||||
|
Object = ""
|
||||||
|
Action = "Discovery failed"
|
||||||
|
Rows = 0
|
||||||
|
File = ""
|
||||||
|
Error = $_.Exception.Message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidatePath = Join-Path $runDirectory "candidate_objects.csv"
|
||||||
|
if ($allCandidates.Count -gt 0) {
|
||||||
|
$allCandidates |
|
||||||
|
Sort-Object DatabaseName, @{ Expression = "Score"; Descending = $true }, ObjectName |
|
||||||
|
Export-Csv -LiteralPath $candidatePath -NoTypeInformation -Encoding UTF8 -Delimiter ";"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
"" | Set-Content -LiteralPath $candidatePath -Encoding UTF8
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ExportSamples -and $allCandidates.Count -gt 0) {
|
||||||
|
$sampleObjects = @(
|
||||||
|
$allCandidates |
|
||||||
|
Sort-Object @{ Expression = "Score"; Descending = $true }, DatabaseName, ObjectName |
|
||||||
|
Select-Object -First $MaxSampleObjects
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($candidate in $sampleObjects) {
|
||||||
|
$db = [string]$candidate.DatabaseName
|
||||||
|
$schema = [string]$candidate.SchemaName
|
||||||
|
$objectName = [string]$candidate.ObjectName
|
||||||
|
$fileName = Normalize-FileName "sample_$db.$schema.$objectName.csv"
|
||||||
|
$samplePath = Join-Path $runDirectory $fileName
|
||||||
|
|
||||||
|
try {
|
||||||
|
Write-Host "Exporting sample: $db.$schema.$objectName"
|
||||||
|
$sql = Build-SampleSql -SchemaName $schema -ObjectName $objectName -Rows $SampleRows
|
||||||
|
$rows = Export-QueryToCsv -DbName $db -Sql $sql -Path $samplePath
|
||||||
|
|
||||||
|
$summary.Add([pscustomobject]@{
|
||||||
|
Created = (Get-Date -Format "yyyy-MM-dd HH:mm:ss")
|
||||||
|
Database = $db
|
||||||
|
Object = "$schema.$objectName"
|
||||||
|
Action = "Sample exported"
|
||||||
|
Rows = $rows
|
||||||
|
File = $fileName
|
||||||
|
Error = ""
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$summary.Add([pscustomobject]@{
|
||||||
|
Created = (Get-Date -Format "yyyy-MM-dd HH:mm:ss")
|
||||||
|
Database = $db
|
||||||
|
Object = "$schema.$objectName"
|
||||||
|
Action = "Sample export failed"
|
||||||
|
Rows = 0
|
||||||
|
File = $fileName
|
||||||
|
Error = $_.Exception.Message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$summaryPath = Join-Path $runDirectory "export_summary.csv"
|
||||||
|
$summary | Export-Csv -LiteralPath $summaryPath -NoTypeInformation -Encoding UTF8 -Delimiter ";"
|
||||||
|
|
||||||
|
$readmePath = Join-Path $runDirectory "README.txt"
|
||||||
|
@"
|
||||||
|
Alphaplan SQL discovery export
|
||||||
|
==============================
|
||||||
|
|
||||||
|
Created: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss")
|
||||||
|
Server instance: $ServerInstance
|
||||||
|
Database filter: $(if ([string]::IsNullOrWhiteSpace($Database)) { "(all accessible user databases)" } else { $Database })
|
||||||
|
Export samples: $ExportSamples
|
||||||
|
Sample rows: $SampleRows
|
||||||
|
|
||||||
|
Files:
|
||||||
|
- candidate_objects.csv: SQL tables/views that look relevant for finance, invoices, sales, customers, articles or amounts.
|
||||||
|
- export_summary.csv: discovery and optional sample export status.
|
||||||
|
- sample_*.csv: optional small samples from top candidate objects when -ExportSamples is used.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
- The script only reads SQL Server metadata and data.
|
||||||
|
- It does not change Alphaplan or SQL Server.
|
||||||
|
- Sample exports are limited with SELECT TOP.
|
||||||
|
- This is Phase 1 only. The BiDashboard import mapping is a separate step.
|
||||||
|
|
||||||
|
Recommended next step:
|
||||||
|
Send candidate_objects.csv and export_summary.csv to the Alphaplan/DE key user or IT.
|
||||||
|
They should identify the correct invoice header, invoice line, customer, article and credit note/storno objects.
|
||||||
|
"@ | Set-Content -LiteralPath $readmePath -Encoding UTF8
|
||||||
|
|
||||||
|
if (-not $SkipUpload) {
|
||||||
|
$resolvedRclone = Resolve-RcloneExecutable -ConfiguredPath $RcloneExe
|
||||||
|
$target = "${RcloneRemote}:$RcloneTarget"
|
||||||
|
$rcloneLog = Join-Path $logDirectory ("rclone-alphaplan-discovery-" + (Get-Date -Format "yyyyMMdd") + ".log")
|
||||||
|
|
||||||
|
Write-Host "Using rclone: $resolvedRclone"
|
||||||
|
Write-Host "Checking SharePoint target: $target"
|
||||||
|
& $resolvedRclone mkdir $target --log-file $rcloneLog --log-level INFO
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Throw-RcloneError -Message "Could not create/check SharePoint target '$target'. rclone exit code $LASTEXITCODE." -LogPath $rcloneLog
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetListing = & $resolvedRclone lsf $target --max-depth 1 --log-file $rcloneLog --log-level INFO
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Throw-RcloneError -Message "SharePoint target '$target' is not reachable. rclone exit code $LASTEXITCODE." -LogPath $rcloneLog
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "SharePoint target reachable. Existing items: $(@($targetListing).Count)"
|
||||||
|
Write-Host "Uploading discovery folder to SharePoint target: $target"
|
||||||
|
& $resolvedRclone copy $runDirectory $target `
|
||||||
|
--include "*.csv" `
|
||||||
|
--include "*.txt" `
|
||||||
|
--log-file $rcloneLog `
|
||||||
|
--log-level INFO
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Throw-RcloneError -Message "rclone upload failed with exit code $LASTEXITCODE." -LogPath $rcloneLog
|
||||||
|
}
|
||||||
|
|
||||||
|
$uploadedCandidate = & $resolvedRclone lsf $target --files-only --include "candidate_objects.csv" --log-file $rcloneLog --log-level INFO
|
||||||
|
if ($LASTEXITCODE -ne 0 -or -not ($uploadedCandidate | Where-Object { $_ -eq "candidate_objects.csv" })) {
|
||||||
|
Throw-RcloneError -Message "Upload verification failed. candidate_objects.csv was not listed in '$target'." -LogPath $rcloneLog
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Upload verified."
|
||||||
|
Write-Host "rclone log: $rcloneLog"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "Upload skipped because -SkipUpload was used."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Alphaplan discovery finished."
|
||||||
|
Write-Host "Local export: $runDirectory"
|
||||||
|
Write-Host "Candidates: $candidatePath"
|
||||||
|
Write-Host "Summary: $summaryPath"
|
||||||
|
Write-Host "Candidate count: $($allCandidates.Count)"
|
||||||
|
Write-Host "Upload target: $(if ($SkipUpload) { '(skipped)' } else { "${RcloneRemote}:$RcloneTarget" })"
|
||||||
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
@@ -0,0 +1,111 @@
|
|||||||
|
@using TrafagSalesExporter.Services
|
||||||
|
@inject IAdminAccessService AdminAccess
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IUiTextService UiText
|
||||||
|
@inject ILogger<AdminAccessPanel> Logger
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="1" Style="max-width:520px;">
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined">
|
||||||
|
@T("Adminbereich ist geschützt. Bitte anmelden.", "Admin area is protected. Please sign in.")
|
||||||
|
</MudAlert>
|
||||||
|
@if (!AdminAccess.IsConfigured)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Error" Variant="Variant.Filled">
|
||||||
|
@T("Admin-Zugang ist noch nicht konfiguriert.", "Admin access is not configured yet.")
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
<form method="post" action="@AccessUrl">
|
||||||
|
<input type="hidden" name="returnUrl" value="@Navigation.Uri" />
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
<MudTextField T="string" Name="username" Label="@T("Name", "Name")" Disabled="@(!AdminAccess.IsConfigured)" />
|
||||||
|
<MudTextField T="string" Name="password" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!AdminAccess.IsConfigured)" />
|
||||||
|
<button type="submit" class="mud-button-root mud-button mud-button-filled mud-button-filled-primary mud-button-filled-size-medium mud-ripple">
|
||||||
|
@T("Admin entsperren", "Unlock admin")
|
||||||
|
</button>
|
||||||
|
</MudStack>
|
||||||
|
</form>
|
||||||
|
<MudText Typo="Typo.caption">
|
||||||
|
@T("Server-Klicks", "Server clicks"): @_unlockClickCount |
|
||||||
|
@T("Konfiguriert", "Configured"): @(AdminAccess.IsConfigured ? "JA" : "NEIN")
|
||||||
|
</MudText>
|
||||||
|
<MudDivider />
|
||||||
|
<MudExpansionPanels Elevation="0">
|
||||||
|
<MudExpansionPanel Text="@T("Passwort ändern", "Change password")" Icon="@Icons.Material.Filled.Password">
|
||||||
|
<MudStack Spacing="3" Class="pt-2">
|
||||||
|
<MudTextField @bind-Value="_changeUsername" Label="@T("Name", "Name")" Disabled="@(!AdminAccess.IsConfigured)" />
|
||||||
|
<MudTextField @bind-Value="_currentPassword" Label="@T("Aktuelles Passwort", "Current password")" InputType="InputType.Password" Disabled="@(!AdminAccess.IsConfigured)" />
|
||||||
|
<MudTextField @bind-Value="_newPassword" Label="@T("Neues Passwort", "New password")" InputType="InputType.Password" HelperText="@T("Mindestens 8 Zeichen.", "At least 8 characters.")" Disabled="@(!AdminAccess.IsConfigured)" />
|
||||||
|
<MudTextField @bind-Value="_newPasswordRepeat" Label="@T("Neues Passwort wiederholen", "Repeat new password")" InputType="InputType.Password" Disabled="@(!AdminAccess.IsConfigured)" />
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="ChangePassword"
|
||||||
|
StartIcon="@Icons.Material.Filled.Save" Disabled="@(!AdminAccess.IsConfigured)">
|
||||||
|
@T("Passwort speichern", "Save password")
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudExpansionPanel>
|
||||||
|
</MudExpansionPanels>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string? _username;
|
||||||
|
private string? _password;
|
||||||
|
private string? _changeUsername;
|
||||||
|
private string? _currentPassword;
|
||||||
|
private string? _newPassword;
|
||||||
|
private string? _newPasswordRepeat;
|
||||||
|
private int _unlockClickCount;
|
||||||
|
private string AccessUrl => new Uri(new Uri(Navigation.BaseUri), "access/admin").ToString();
|
||||||
|
|
||||||
|
private void Unlock()
|
||||||
|
{
|
||||||
|
_unlockClickCount++;
|
||||||
|
Logger.LogInformation(
|
||||||
|
"Admin unlock button handler reached. ClickCount={ClickCount}, IsConfigured={IsConfigured}, UsernameLength={UsernameLength}, PasswordLength={PasswordLength}",
|
||||||
|
_unlockClickCount,
|
||||||
|
AdminAccess.IsConfigured,
|
||||||
|
_username?.Length ?? 0,
|
||||||
|
_password?.Length ?? 0);
|
||||||
|
|
||||||
|
if (!AdminAccess.TryUnlock(_username ?? string.Empty, _password ?? string.Empty))
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Admin-Anmeldung fehlgeschlagen.", "Admin sign-in failed."), Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_password = string.Empty;
|
||||||
|
OnUnlocked.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ChangePassword()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_newPassword) || _newPassword.Length < 8)
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Das neue Passwort muss mindestens 8 Zeichen lang sein.", "The new password must be at least 8 characters long."), Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_newPassword != _newPasswordRepeat)
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Die neuen Passwörter stimmen nicht überein.", "The new passwords do not match."), Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AdminAccess.TryChangePassword(_changeUsername ?? string.Empty, _currentPassword ?? string.Empty, _newPassword))
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Passwort konnte nicht geändert werden. Name oder aktuelles Passwort prüfen.", "Password could not be changed. Check the name or current password."), Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentPassword = string.Empty;
|
||||||
|
_newPassword = string.Empty;
|
||||||
|
_newPasswordRepeat = string.Empty;
|
||||||
|
Snackbar.Add(T("Passwort wurde geändert.", "Password has been changed."), Severity.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback OnUnlocked { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Trafag Finanze/Sales Management Cockpit</title>
|
||||||
|
<base href="@BaseHref" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||||
|
<link href="css/app.css" rel="stylesheet" />
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,400,600,700,800&display=swap" rel="stylesheet" />
|
||||||
|
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
|
||||||
|
<HeadOutlet @rendermode="@Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<Routes @rendermode="@Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer" />
|
||||||
|
<script src="@($"{BaseHref}_framework/blazor.web.js")" autostart="false"></script>
|
||||||
|
<script>
|
||||||
|
Blazor.start({
|
||||||
|
circuit: {
|
||||||
|
configureSignalR: builder => builder.withUrl('@($"{BaseHref}_blazor")')
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||||
|
<script src="js/download.js"></script>
|
||||||
|
<script src="js/vendor/three.min.js"></script>
|
||||||
|
<script src="js/finance3d.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject]
|
||||||
|
private IConfiguration Configuration { get; set; } = default!;
|
||||||
|
|
||||||
|
private string BaseHref
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var pathBase = Configuration["ASPNETCORE_PATHBASE"]?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(pathBase) || pathBase == "/")
|
||||||
|
return "/";
|
||||||
|
|
||||||
|
pathBase = "/" + pathBase.Trim('/');
|
||||||
|
return $"{pathBase}/";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
@using TrafagSalesExporter.Services
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
|
@inject IFinanceCockpitAccessService FinanceAccess
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject IUiTextService UiText
|
||||||
|
@inject ILogger<FinanceCockpitUnlockPanel> Logger
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-4">@T("Finance Cockpit", "Finance Cockpit")</MudText>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="1" Style="max-width:520px;">
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined">
|
||||||
|
@T("Finance Cockpit ist geschuetzt. Bitte separat anmelden.", "Finance Cockpit is protected. Please sign in separately.")
|
||||||
|
</MudAlert>
|
||||||
|
@if (!FinanceAccess.IsConfigured)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Error" Variant="Variant.Filled">
|
||||||
|
@T("Finance-Cockpit-Zugang ist noch nicht konfiguriert. Bitte Username und PasswordHash in FinanceCockpitAccess konfigurieren.", "Finance Cockpit access is not configured yet. Please configure Username and PasswordHash in FinanceCockpitAccess.")
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
<form method="post" action="@AccessUrl">
|
||||||
|
<input type="hidden" name="returnUrl" value="@Navigation.Uri" />
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
<MudTextField T="string" Name="username" Label="@T("Name", "Name")" Disabled="@(!FinanceAccess.IsConfigured)" />
|
||||||
|
<MudTextField T="string" Name="password" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!FinanceAccess.IsConfigured)" />
|
||||||
|
<button type="submit" class="mud-button-root mud-button mud-button-filled mud-button-filled-primary mud-button-filled-size-medium mud-ripple">
|
||||||
|
@T("Finance Cockpit entsperren", "Unlock Finance Cockpit")
|
||||||
|
</button>
|
||||||
|
</MudStack>
|
||||||
|
</form>
|
||||||
|
<MudText Typo="Typo.caption">
|
||||||
|
@T("Server-Klicks", "Server clicks"): @_unlockClickCount |
|
||||||
|
@T("Konfiguriert", "Configured"): @(FinanceAccess.IsConfigured ? "JA" : "NEIN")
|
||||||
|
</MudText>
|
||||||
|
<MudDivider />
|
||||||
|
<MudExpansionPanels Elevation="0">
|
||||||
|
<MudExpansionPanel Text="@T("Passwort ändern", "Change password")" Icon="@Icons.Material.Filled.Password">
|
||||||
|
<MudStack Spacing="3" Class="pt-2">
|
||||||
|
<MudTextField @bind-Value="_changeUsername" Label="@T("Name", "Name")" Disabled="@(!FinanceAccess.IsConfigured)" />
|
||||||
|
<MudTextField @bind-Value="_currentPassword" Label="@T("Aktuelles Passwort", "Current password")" InputType="InputType.Password" Disabled="@(!FinanceAccess.IsConfigured)" />
|
||||||
|
<MudTextField @bind-Value="_newPassword" Label="@T("Neues Passwort", "New password")" InputType="InputType.Password" HelperText="@T("Mindestens 8 Zeichen.", "At least 8 characters.")" Disabled="@(!FinanceAccess.IsConfigured)" />
|
||||||
|
<MudTextField @bind-Value="_newPasswordRepeat" Label="@T("Neues Passwort wiederholen", "Repeat new password")" InputType="InputType.Password" Disabled="@(!FinanceAccess.IsConfigured)" />
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="ChangePasswordAsync"
|
||||||
|
StartIcon="@Icons.Material.Filled.Save" Disabled="@(!FinanceAccess.IsConfigured)">
|
||||||
|
@T("Passwort speichern", "Save password")
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudExpansionPanel>
|
||||||
|
</MudExpansionPanels>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string? _username;
|
||||||
|
private string? _password;
|
||||||
|
private string? _changeUsername;
|
||||||
|
private string? _currentPassword;
|
||||||
|
private string? _newPassword;
|
||||||
|
private string? _newPasswordRepeat;
|
||||||
|
private int _unlockClickCount;
|
||||||
|
private string AccessUrl => new Uri(new Uri(Navigation.BaseUri), "access/finance").ToString();
|
||||||
|
|
||||||
|
private Task UnlockAsync()
|
||||||
|
{
|
||||||
|
_unlockClickCount++;
|
||||||
|
Logger.LogInformation(
|
||||||
|
"Finance unlock button handler reached. ClickCount={ClickCount}, IsConfigured={IsConfigured}, UsernameLength={UsernameLength}, PasswordLength={PasswordLength}",
|
||||||
|
_unlockClickCount,
|
||||||
|
FinanceAccess.IsConfigured,
|
||||||
|
_username?.Length ?? 0,
|
||||||
|
_password?.Length ?? 0);
|
||||||
|
|
||||||
|
if (!FinanceAccess.TryUnlock(_username ?? string.Empty, _password ?? string.Empty))
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Finance-Cockpit-Anmeldung fehlgeschlagen.", "Finance Cockpit sign-in failed."), Severity.Error);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
_password = string.Empty;
|
||||||
|
Navigation.Refresh(forceReload: false);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task ChangePasswordAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_newPassword) || _newPassword.Length < 8)
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Das neue Passwort muss mindestens 8 Zeichen lang sein.", "The new password must be at least 8 characters long."), Severity.Warning);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_newPassword != _newPasswordRepeat)
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Die neuen Passwörter stimmen nicht überein.", "The new passwords do not match."), Severity.Warning);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!FinanceAccess.TryChangePassword(_changeUsername ?? string.Empty, _currentPassword ?? string.Empty, _newPassword))
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Passwort konnte nicht geändert werden. Name oder aktuelles Passwort prüfen.", "Password could not be changed. Check the name or current password."), Severity.Error);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentPassword = string.Empty;
|
||||||
|
_newPassword = string.Empty;
|
||||||
|
_newPasswordRepeat = string.Empty;
|
||||||
|
Snackbar.Add(T("Passwort wurde geändert.", "Password has been changed."), Severity.Success);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
}
|
||||||
@@ -0,0 +1,943 @@
|
|||||||
|
@using Microsoft.AspNetCore.Components
|
||||||
|
@using TrafagSalesExporter.Models
|
||||||
|
@using TrafagSalesExporter.Services
|
||||||
|
@inject IUiTextService UiText
|
||||||
|
@inject IJSRuntime JsRuntime
|
||||||
|
|
||||||
|
<MudTabs Elevation="1" Rounded="false" PanelClass="pt-4">
|
||||||
|
<MudTabPanel Text="@T("Ueberblick", "Overview")" Icon="@Icons.Material.Filled.Dashboard">
|
||||||
|
@PrintToolbar("hr-kpi-print-overview", T("Ueberblick als PDF", "Overview as PDF"))
|
||||||
|
<div id="hr-kpi-print-overview" class="hr-print-section">
|
||||||
|
@PrintHeader(T("Ueberblick", "Overview"))
|
||||||
|
@MetricGrid(Result.Metrics)
|
||||||
|
|
||||||
|
<MudGrid Class="mt-4">
|
||||||
|
<MudItem xs="12" md="7">
|
||||||
|
@TrafficLightPanel(Result.TrafficLights)
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="5">
|
||||||
|
@MetricGrid(Result.PeriodComparisonMetrics)
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<MudGrid Class="mt-4">
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
@HeadcountByOrganisationTable(Result.HeadcountByOrganisation)
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
@CriticalBalancesTable(Result.CriticalTimeBalances)
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</div>
|
||||||
|
</MudTabPanel>
|
||||||
|
|
||||||
|
<MudTabPanel Text="@T("Fluktuation", "Turnover")" Icon="@Icons.Material.Filled.TrendingDown">
|
||||||
|
@PrintToolbar("hr-kpi-print-turnover", T("Fluktuation als PDF", "Turnover as PDF"))
|
||||||
|
<div id="hr-kpi-print-turnover" class="hr-print-section">
|
||||||
|
@PrintHeader(T("Fluktuation", "Turnover"))
|
||||||
|
@MetricGrid(Result.TurnoverMetrics)
|
||||||
|
|
||||||
|
<MudGrid Class="mt-4">
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
@TurnoverRelevantTable(Result.FluctuationRelevantLeavers)
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
@LeaverExclusionTable(Result.Leavers)
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<MudGrid Class="mt-4">
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
@GroupValueTable(T("Austritte nach Austrittsart", "Leavers by exit type"), Result.LeaversByType, T("Austritte", "Leavers"))
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
@GroupValueTable(T("Austritte nach Organisation", "Leavers by organisation"), Result.LeaversByOrganisation, T("Austritte", "Leavers"))
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<MudGrid Class="mt-4">
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
@TurnoverGauge(Result.TurnoverVisuals)
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
@TurnoverFunnel(Result.TurnoverVisuals.FunnelSteps)
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
@TurnoverDonut(Result.TurnoverVisuals.ExclusionReasons)
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<MudGrid Class="mt-4">
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
@HorizontalBars(Result.TurnoverVisuals.RelevantByOrganisation)
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
@MonthlyBars(Result.TurnoverVisuals)
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</div>
|
||||||
|
</MudTabPanel>
|
||||||
|
|
||||||
|
<MudTabPanel Text="@T("Ampel", "Status")" Icon="@Icons.Material.Filled.Traffic">
|
||||||
|
@PrintToolbar("hr-kpi-print-status", T("Ampel als PDF", "Status as PDF"))
|
||||||
|
<div id="hr-kpi-print-status" class="hr-print-section">
|
||||||
|
@PrintHeader(T("Ampel", "Status"))
|
||||||
|
@TrafficLightPanel(Result.TrafficLights)
|
||||||
|
@MetricGrid(Result.PeriodComparisonMetrics)
|
||||||
|
</div>
|
||||||
|
</MudTabPanel>
|
||||||
|
|
||||||
|
<MudTabPanel Text="@T("Absenzen", "Absences")" Icon="@Icons.Material.Filled.Sick">
|
||||||
|
@PrintToolbar("hr-kpi-print-absences", T("Absenzen als PDF", "Absences as PDF"))
|
||||||
|
<div id="hr-kpi-print-absences" class="hr-print-section">
|
||||||
|
@PrintHeader(T("Absenzen", "Absences"))
|
||||||
|
@MetricGrid(Result.AbsenceMetrics)
|
||||||
|
<MudGrid Class="mt-4">
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
@GroupValueTable(T("Absenzen nach Organisation", "Absences by organisation"), Result.AbsenceByOrganisation, T("Krankheitstage", "Sick days"))
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
@TopAbsencesTable(Result.Absences)
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Absenzen je Mitarbeiter", "Absences by employee")</MudText>
|
||||||
|
<MudTable Items="Result.Absences.OrderByDescending(x => x.KrankheitstageGesamt).Take(100)" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Personalnr.", "Personnel no.")</MudTh>
|
||||||
|
<MudTh>@T("Name", "Name")</MudTh>
|
||||||
|
<MudTh>@T("Organisation", "Organisation")</MudTh>
|
||||||
|
<MudTh>@T("Kurz", "Short")</MudTh>
|
||||||
|
<MudTh>@T("Lang", "Long")</MudTh>
|
||||||
|
<MudTh>@T("Gesamt", "Total")</MudTh>
|
||||||
|
<MudTh>@T("Quote", "Rate")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Personalnummer</MudTd>
|
||||||
|
<MudTd>@DisplayPersonName(context.Name, context.Personalnummer, Result.Options.ManagementView)</MudTd>
|
||||||
|
<MudTd>@context.Organisationseinheit</MudTd>
|
||||||
|
<MudTd>@context.KrankheitstageKurz.ToString("N1")</MudTd>
|
||||||
|
<MudTd>@context.KrankheitstageLang.ToString("N1")</MudTd>
|
||||||
|
<MudTd>@context.KrankheitstageGesamt.ToString("N1")</MudTd>
|
||||||
|
<MudTd>@context.KrankenquoteMa.ToString("P1")</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
<PagerContent>
|
||||||
|
<MudTablePager />
|
||||||
|
</PagerContent>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>
|
||||||
|
</div>
|
||||||
|
</MudTabPanel>
|
||||||
|
|
||||||
|
<MudTabPanel Text="@T("Zeit / Ferien", "Time / Vacation")" Icon="@Icons.Material.Filled.EventAvailable">
|
||||||
|
@PrintToolbar("hr-kpi-print-time-vacation", T("Zeit/Ferien als PDF", "Time/vacation as PDF"))
|
||||||
|
<div id="hr-kpi-print-time-vacation" class="hr-print-section">
|
||||||
|
@PrintHeader(T("Zeit / Ferien", "Time / Vacation"))
|
||||||
|
@MetricGrid(Result.TimeVacationMetrics)
|
||||||
|
|
||||||
|
<MudGrid Class="mt-4">
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
@CriticalBalancesTable(Result.CriticalTimeBalances)
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Kritische Restferien", "Critical vacation balance")</MudText>
|
||||||
|
<MudTable Items="Result.Employees.OrderByDescending(x => x.UrlaubRest).Take(25)" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Name", "Name")</MudTh>
|
||||||
|
<MudTh>@T("Organisation", "Organisation")</MudTh>
|
||||||
|
<MudTh>@T("Rest", "Left")</MudTh>
|
||||||
|
<MudTh>@T("Ausstehend", "Open")</MudTh>
|
||||||
|
<MudTh>@T("Ampel", "Status")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@DisplayPersonName(context.NameVoll, context.Personalnummer, Result.Options.ManagementView)</MudTd>
|
||||||
|
<MudTd>@context.Organisationseinheit</MudTd>
|
||||||
|
<MudTd>@context.UrlaubRest.ToString("N1")</MudTd>
|
||||||
|
<MudTd>@context.FerienAusstehend.ToString("N1")</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="@TrafficLightColor(context.RestferienAmpel)" Variant="Variant.Outlined">
|
||||||
|
@context.RestferienAmpel
|
||||||
|
</MudChip>
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</div>
|
||||||
|
</MudTabPanel>
|
||||||
|
|
||||||
|
<MudTabPanel Text="@T("Mitarbeitende", "Employees")" Icon="@Icons.Material.Filled.Groups">
|
||||||
|
@PrintToolbar("hr-kpi-print-employees", T("Mitarbeitende als PDF", "Employees as PDF"))
|
||||||
|
<div id="hr-kpi-print-employees" class="hr-print-section">
|
||||||
|
@PrintHeader(T("Mitarbeitende", "Employees"))
|
||||||
|
@EmployeesTable(Result.Employees)
|
||||||
|
</div>
|
||||||
|
</MudTabPanel>
|
||||||
|
|
||||||
|
<MudTabPanel Text="@T("Datenstatus", "Data status")" Icon="@Icons.Material.Filled.FactCheck">
|
||||||
|
@PrintToolbar("hr-kpi-print-data-status", T("Datenstatus als PDF", "Data status as PDF"))
|
||||||
|
<div id="hr-kpi-print-data-status" class="hr-print-section">
|
||||||
|
@PrintHeader(T("Datenstatus", "Data status"))
|
||||||
|
@FileStatusTable(Result.FileStatuses)
|
||||||
|
<MudGrid Class="mt-4">
|
||||||
|
<MudItem xs="12">
|
||||||
|
@DataQualityTable(Result.DataQualityIssues)
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</div>
|
||||||
|
</MudTabPanel>
|
||||||
|
|
||||||
|
<MudTabPanel Text="@T("Anleitung", "Guide")" Icon="@Icons.Material.Filled.HelpOutline">
|
||||||
|
@GuidePanel()
|
||||||
|
</MudTabPanel>
|
||||||
|
</MudTabs>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, EditorRequired] public HrKpiResult Result { get; set; } = new();
|
||||||
|
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
|
||||||
|
private RenderFragment PrintToolbar(string targetId, string label) => @<MudStack Row Justify="Justify.FlexEnd" Class="mb-3 hr-print-toolbar">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Size="Size.Small"
|
||||||
|
StartIcon="@Icons.Material.Filled.PictureAsPdf"
|
||||||
|
OnClick="@(() => PrintSectionAsync(targetId))">
|
||||||
|
@label
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>;
|
||||||
|
|
||||||
|
private RenderFragment PrintHeader(string title) => @<div class="hr-print-header">
|
||||||
|
<h1>@title</h1>
|
||||||
|
<p>@Result.Options.DataFolder</p>
|
||||||
|
<p>@BuildFilterSummary()</p>
|
||||||
|
</div>;
|
||||||
|
|
||||||
|
private async Task PrintSectionAsync(string targetId)
|
||||||
|
{
|
||||||
|
await JsRuntime.InvokeVoidAsync("trafagDownload.printElement", targetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildFilterSummary()
|
||||||
|
{
|
||||||
|
var parts = new List<string>();
|
||||||
|
if (Result.Options.FromDate.HasValue || Result.Options.ToDate.HasValue)
|
||||||
|
parts.Add($"{T("Zeitraum", "Period")}: {FormatDate(Result.Options.FromDate)} - {FormatDate(Result.Options.ToDate)}");
|
||||||
|
if (Result.Options.Year.HasValue)
|
||||||
|
parts.Add($"{T("Austrittsjahr", "Leaver year")}: {Result.Options.Year.Value}");
|
||||||
|
parts.Add($"{T("Organisation", "Organisation")}: {BlankAsAll(Result.Options.Organisationseinheit)}");
|
||||||
|
parts.Add($"{T("Mitarbeitertyp", "Employee type")}: {BlankAsAll(Result.Options.Mitarbeitertyp)}");
|
||||||
|
if (!string.IsNullOrWhiteSpace(Result.Options.KostenstelleText))
|
||||||
|
parts.Add($"{T("Kostenstelle", "Cost center")}: {Result.Options.KostenstelleText}");
|
||||||
|
return string.Join(" | ", parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BlankAsAll(string? value)
|
||||||
|
=> string.IsNullOrWhiteSpace(value) ? T("Alle", "All") : value;
|
||||||
|
|
||||||
|
private static Color MetricColor(string severity)
|
||||||
|
=> severity == "Warning" ? Color.Warning : Color.Default;
|
||||||
|
|
||||||
|
private static Color TrafficLightColor(string value)
|
||||||
|
=> value switch
|
||||||
|
{
|
||||||
|
"Rot" => Color.Error,
|
||||||
|
"Gelb" => Color.Warning,
|
||||||
|
_ => Color.Success
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Color MapQualityColor(string severity)
|
||||||
|
=> severity switch
|
||||||
|
{
|
||||||
|
"Error" => Color.Error,
|
||||||
|
"Warning" => Color.Warning,
|
||||||
|
_ => Color.Info
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string DisplayPersonName(string name, int? personalnummer, bool managementView)
|
||||||
|
=> managementView
|
||||||
|
? (personalnummer.HasValue ? $"Personalnr. {personalnummer.Value}" : "Person anonymisiert")
|
||||||
|
: name;
|
||||||
|
|
||||||
|
private static string FormatDate(DateTime? value)
|
||||||
|
=> value?.ToString("dd.MM.yyyy") ?? "-";
|
||||||
|
|
||||||
|
private RenderFragment<IReadOnlyList<HrKpiMetric>> MetricGrid => metrics => @<MudGrid Class="mb-4">
|
||||||
|
@foreach (var metric in metrics)
|
||||||
|
{
|
||||||
|
<MudItem xs="12" sm="6" md="3" lg="2">
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.caption">@metric.Label</MudText>
|
||||||
|
<MudText Typo="Typo.h5">@metric.Value</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Color="@MetricColor(metric.Severity)">@metric.Detail</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
}
|
||||||
|
</MudGrid>;
|
||||||
|
|
||||||
|
private RenderFragment<IReadOnlyList<HrKpiGroupValue>> HeadcountByOrganisationTable => items => @<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Headcount nach Organisation", "Headcount by organisation")</MudText>
|
||||||
|
<MudTable Items="items" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Organisation", "Organisation")</MudTh>
|
||||||
|
<MudTh>@T("Headcount", "Headcount")</MudTh>
|
||||||
|
<MudTh>FTE</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Label</MudTd>
|
||||||
|
<MudTd>@context.Count.ToString("N0")</MudTd>
|
||||||
|
<MudTd>@context.Value.ToString("N1")</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>;
|
||||||
|
|
||||||
|
private RenderFragment<IReadOnlyList<HrKpiTrafficLight>> TrafficLightPanel => items => @<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("HR-Ampel", "HR status")</MudText>
|
||||||
|
<MudGrid>
|
||||||
|
@foreach (var item in items)
|
||||||
|
{
|
||||||
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
<MudPaper Class="pa-3" Elevation="0">
|
||||||
|
<MudStack Row AlignItems="AlignItems.Center" Spacing="2">
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="@TrafficLightColor(item.Status)" Variant="Variant.Filled">
|
||||||
|
@item.Status
|
||||||
|
</MudChip>
|
||||||
|
<MudText Typo="Typo.subtitle2">@item.Area</MudText>
|
||||||
|
</MudStack>
|
||||||
|
<MudText Typo="Typo.h6">@item.Value</MudText>
|
||||||
|
<MudText Typo="Typo.body2">@item.Detail</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
}
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>;
|
||||||
|
|
||||||
|
private RenderFragment<IReadOnlyList<HrKpiDataQualityIssue>> DataQualityTable => items => @<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Datenqualitaet", "Data quality")</MudText>
|
||||||
|
<MudTable Items="items" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Schwere", "Severity")</MudTh>
|
||||||
|
<MudTh>@T("Bereich", "Area")</MudTh>
|
||||||
|
<MudTh>@T("Pruefpunkt", "Check")</MudTh>
|
||||||
|
<MudTh>@T("Anzahl", "Count")</MudTh>
|
||||||
|
<MudTh>@T("Hinweis", "Note")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="@MapQualityColor(context.Severity)" Variant="Variant.Outlined">
|
||||||
|
@context.Severity
|
||||||
|
</MudChip>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>@context.Area</MudTd>
|
||||||
|
<MudTd>@context.Issue</MudTd>
|
||||||
|
<MudTd>@context.Count.ToString("N0")</MudTd>
|
||||||
|
<MudTd>@context.Detail</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
<NoRecordsContent>
|
||||||
|
<MudText Typo="Typo.body2">@T("Keine Datenqualitaetswarnungen.", "No data quality warnings.")</MudText>
|
||||||
|
</NoRecordsContent>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>;
|
||||||
|
|
||||||
|
private RenderFragment<(string Title, IReadOnlyList<HrKpiGroupValue> Items, string ValueLabel)> GroupValueTableTuple => data => @<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@data.Title</MudText>
|
||||||
|
<MudTable Items="data.Items" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Gruppe", "Group")</MudTh>
|
||||||
|
<MudTh>@data.ValueLabel</MudTh>
|
||||||
|
<MudTh>%</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Label</MudTd>
|
||||||
|
<MudTd>@(context.Value != 0 ? context.Value.ToString("N1") : context.Count.ToString("N0"))</MudTd>
|
||||||
|
<MudTd>@context.Percent.ToString("N1")</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>;
|
||||||
|
|
||||||
|
private RenderFragment GroupValueTable(string title, IReadOnlyList<HrKpiGroupValue> items, string valueLabel)
|
||||||
|
=> GroupValueTableTuple((title, items, valueLabel));
|
||||||
|
|
||||||
|
private RenderFragment<IReadOnlyList<HrAbsenceRow>> TopAbsencesTable => items => @<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Hoechste Absenzen", "Highest absences")</MudText>
|
||||||
|
<MudTable Items="items.OrderByDescending(x => x.KrankheitstageGesamt).Take(25)" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Name", "Name")</MudTh>
|
||||||
|
<MudTh>@T("Organisation", "Organisation")</MudTh>
|
||||||
|
<MudTh>@T("Kurz", "Short")</MudTh>
|
||||||
|
<MudTh>@T("Lang", "Long")</MudTh>
|
||||||
|
<MudTh>@T("Gesamt", "Total")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@DisplayPersonName(context.Name, context.Personalnummer, Result.Options.ManagementView)</MudTd>
|
||||||
|
<MudTd>@context.Organisationseinheit</MudTd>
|
||||||
|
<MudTd>@context.KrankheitstageKurz.ToString("N1")</MudTd>
|
||||||
|
<MudTd>@context.KrankheitstageLang.ToString("N1")</MudTd>
|
||||||
|
<MudTd>@context.KrankheitstageGesamt.ToString("N1")</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>;
|
||||||
|
|
||||||
|
private RenderFragment<IReadOnlyList<HrKpiEmployeeRow>> CriticalBalancesTable => items => @<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Kritische GLZ-Saldi", "Critical time balances")</MudText>
|
||||||
|
<MudTable Items="items" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Name", "Name")</MudTh>
|
||||||
|
<MudTh>@T("Organisation", "Organisation")</MudTh>
|
||||||
|
<MudTh>@T("Saldo", "Balance")</MudTh>
|
||||||
|
<MudTh>@T("Ampel", "Status")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@DisplayPersonName(context.NameVoll, context.Personalnummer, Result.Options.ManagementView)</MudTd>
|
||||||
|
<MudTd>@context.Organisationseinheit</MudTd>
|
||||||
|
<MudTd>@context.StundenSaldo.ToString("N1")</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="@TrafficLightColor(context.GlzAmpel)" Variant="Variant.Outlined">
|
||||||
|
@context.GlzAmpel
|
||||||
|
</MudChip>
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>;
|
||||||
|
|
||||||
|
private RenderFragment<IReadOnlyList<HrLeaverRow>> TurnoverRelevantTable => items => @<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Fluktuationsrelevante Austritte", "Turnover relevant leavers")</MudText>
|
||||||
|
<MudTable Items="items" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Name", "Name")</MudTh>
|
||||||
|
<MudTh>@T("Austritt", "Exit")</MudTh>
|
||||||
|
<MudTh>@T("Organisation", "Organisation")</MudTh>
|
||||||
|
<MudTh>@T("Austrittsart", "Exit type")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@DisplayPersonName(context.NameVoll, context.Personalnummer, Result.Options.ManagementView)</MudTd>
|
||||||
|
<MudTd>@FormatDate(context.Austrittsdatum)</MudTd>
|
||||||
|
<MudTd>@context.Organisationseinheit</MudTd>
|
||||||
|
<MudTd>@context.Austrittsart</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>;
|
||||||
|
|
||||||
|
private RenderFragment<IReadOnlyList<HrLeaverRow>> LeaverExclusionTable => items => @<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Ausschlussgruende", "Exclusion reasons")</MudText>
|
||||||
|
<MudTable Items="BuildLeaverExclusionRows(items)" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Grund", "Reason")</MudTh>
|
||||||
|
<MudTh>@T("Anzahl", "Count")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Label</MudTd>
|
||||||
|
<MudTd>@context.Count.ToString("N0")</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>;
|
||||||
|
|
||||||
|
private RenderFragment<IReadOnlyList<HrKpiEmployeeRow>> EmployeesTable => items => @<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Mitarbeitende", "Employees")</MudText>
|
||||||
|
<MudTable Items="items.Take(250)" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Personalnr.", "Personnel no.")</MudTh>
|
||||||
|
<MudTh>@T("Name", "Name")</MudTh>
|
||||||
|
<MudTh>@T("Organisation", "Organisation")</MudTh>
|
||||||
|
<MudTh>@T("Kostenstelle", "Cost center")</MudTh>
|
||||||
|
<MudTh>FTE</MudTh>
|
||||||
|
<MudTh>@T("Alter", "Age")</MudTh>
|
||||||
|
<MudTh>@T("Dienstjahre", "Service years")</MudTh>
|
||||||
|
<MudTh>@T("Typ", "Type")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Personalnummer</MudTd>
|
||||||
|
<MudTd>@DisplayPersonName(context.NameVoll, context.Personalnummer, Result.Options.ManagementView)</MudTd>
|
||||||
|
<MudTd>@context.Organisationseinheit</MudTd>
|
||||||
|
<MudTd>@context.KostenstelleText</MudTd>
|
||||||
|
<MudTd>@context.Fte.ToString("N2")</MudTd>
|
||||||
|
<MudTd>@context.AlterJahre</MudTd>
|
||||||
|
<MudTd>@context.Dienstjahre</MudTd>
|
||||||
|
<MudTd>@context.Mitarbeitertyp</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
<PagerContent>
|
||||||
|
<MudTablePager />
|
||||||
|
</PagerContent>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>;
|
||||||
|
|
||||||
|
private RenderFragment<IReadOnlyList<HrKpiFileStatus>> FileStatusTable => items => @<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Dateistatus", "File status")</MudText>
|
||||||
|
<MudTable Items="items" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Quelle", "Source")</MudTh>
|
||||||
|
<MudTh>@T("Status", "Status")</MudTh>
|
||||||
|
<MudTh>@T("Stand", "Modified")</MudTh>
|
||||||
|
<MudTh>@T("Alter", "Age")</MudTh>
|
||||||
|
<MudTh>@T("Zeilen", "Rows")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>
|
||||||
|
<MudText Typo="Typo.body2">@context.Label</MudText>
|
||||||
|
<MudText Typo="Typo.caption">@context.Path</MudText>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="@(context.Exists ? Color.Success : Color.Error)" Variant="Variant.Outlined">
|
||||||
|
@(context.Message ?? "-")
|
||||||
|
</MudChip>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>@FormatDate(context.LastModified)</MudTd>
|
||||||
|
<MudTd>@(context.AgeDays.HasValue ? $"{context.AgeDays:N0} Tage / {context.FreshnessStatus}" : "-")</MudTd>
|
||||||
|
<MudTd>@context.RowCount.ToString("N0")</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>;
|
||||||
|
|
||||||
|
private RenderFragment GuidePanel() => @<MudGrid>
|
||||||
|
<MudItem xs="12" md="8">
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Ablauf fuer HR", "HR workflow")</MudText>
|
||||||
|
<div class="hr-guide-steps">
|
||||||
|
<div class="hr-guide-step">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Download" Size="Size.Large" />
|
||||||
|
<span>1</span>
|
||||||
|
<strong>@T("Rexx exportieren", "Export from Rexx")</strong>
|
||||||
|
<p>@T("Die benoetigten Rexx-Abfragen manuell herunterladen. Excel/XLSX verwenden, nicht PDF.", "Download the required Rexx queries manually. Use Excel/XLSX, not PDF.")</p>
|
||||||
|
</div>
|
||||||
|
<div class="hr-guide-step">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.FolderCopy" Size="Size.Large" />
|
||||||
|
<span>2</span>
|
||||||
|
<strong>@T("Dateien ablegen", "Place files")</strong>
|
||||||
|
<p>@T("Downloads in den Datenordner kopieren und exakt wie unten benennen.", "Copy downloads into the data folder and name them exactly as listed below.")</p>
|
||||||
|
</div>
|
||||||
|
<div class="hr-guide-step">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Refresh" Size="Size.Large" />
|
||||||
|
<span>3</span>
|
||||||
|
<strong>@T("Cockpit laden", "Load cockpit")</strong>
|
||||||
|
<p>@T("Im HR-KPI-Cockpit den Datenordner kontrollieren und Laden klicken.", "Check the data folder in the HR KPI cockpit and click Load.")</p>
|
||||||
|
</div>
|
||||||
|
<div class="hr-guide-step">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.FactCheck" Size="Size.Large" />
|
||||||
|
<span>4</span>
|
||||||
|
<strong>@T("Datenstatus pruefen", "Check data status")</strong>
|
||||||
|
<p>@T("Im Reiter Datenstatus muessen die erwarteten Dateien gruen erscheinen.", "In the Data status tab, the expected files should be green.")</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Datenordner", "Data folder")</MudText>
|
||||||
|
<MudText Typo="Typo.body1">@Result.Options.DataFolder</MudText>
|
||||||
|
<MudAlert Severity="Severity.Info" Dense Variant="Variant.Outlined" Class="mt-3">
|
||||||
|
@T("Der Standardordner ist konfigurierbar. Fuer einen anderen Ordner oben im HR-KPI-Filter den Datenordner anpassen und neu laden.",
|
||||||
|
"The default folder is configurable. To use another folder, change the data folder in the HR KPI filter above and reload.")
|
||||||
|
</MudAlert>
|
||||||
|
<MudAlert Severity="Severity.Warning" Dense Variant="Variant.Outlined" Class="mt-2">
|
||||||
|
@T("HR-Dateien enthalten Personendaten. Nicht per E-Mail weiterleiten und keine Kopien in ungeschuetzten Ordnern liegen lassen.",
|
||||||
|
"HR files contain personal data. Do not forward them by email and do not leave copies in unprotected folders.")
|
||||||
|
</MudAlert>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Neue Auswertungen im Cockpit", "New cockpit views")</MudText>
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<ul class="mb-0">
|
||||||
|
<li>@T("Managementsicht anonymisiert Personendaten fuer Fuehrungsberichte.", "Management view anonymizes personal data for management reports.")</li>
|
||||||
|
<li>@T("Dateistatus zeigt Pfad, Zeilen, Aenderungsdatum, Alter und Frische.", "File status shows path, rows, modification date, age and freshness.")</li>
|
||||||
|
<li>@T("HR-Ampel fasst Fluktuation, Krankheit, GLZ, Restferien und Datenqualitaet zusammen.", "HR status summarizes turnover, sickness, time balance, vacation balance and data quality.")</li>
|
||||||
|
<li>@T("GLZ- und Restferien-Ampeln koennen gefiltert werden.", "Time-balance and vacation status can be filtered.")</li>
|
||||||
|
<li>@T("Periodenvergleich zeigt die wichtigsten Vorjahreswerte, soweit Daten vorhanden sind.", "Period comparison shows key prior-year values where data is available.")</li>
|
||||||
|
</ul>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<ul class="mb-0">
|
||||||
|
<li>@T("Datenqualitaet markiert fehlende Dateien, alte Dateien und auffaellige Werte.", "Data quality flags missing files, old files and suspicious values.")</li>
|
||||||
|
<li>@T("Austritte werden nach Austrittsart und Organisation gruppiert.", "Leavers are grouped by exit type and organisation.")</li>
|
||||||
|
<li>@T("Absenzen werden nach Organisation ausgewertet.", "Absences are evaluated by organisation.")</li>
|
||||||
|
<li>@T("Top-Absenzen und kritische Detailtabellen helfen bei der operativen Pruefung.", "Top absences and critical detail tables support operational checks.")</li>
|
||||||
|
<li>@T("Drucken/PDF erzeugt eine weitergebbare Ansicht aus dem Browser.", "Print/PDF creates a shareable browser view.")</li>
|
||||||
|
</ul>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Erwartete Dateien", "Expected files")</MudText>
|
||||||
|
<MudTable Items="Result.FileStatuses" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Inhalt", "Content")</MudTh>
|
||||||
|
<MudTh>@T("Datei/Pfad", "File/path")</MudTh>
|
||||||
|
<MudTh>@T("Status", "Status")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Label</MudTd>
|
||||||
|
<MudTd>@context.Path</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="@(context.Exists ? Color.Success : Color.Error)" Variant="Variant.Outlined">
|
||||||
|
@(context.Exists ? T("gefunden", "found") : T("fehlt", "missing"))
|
||||||
|
</MudChip>
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>;
|
||||||
|
|
||||||
|
private static IEnumerable<HrKpiGroupValue> BuildLeaverExclusionRows(IReadOnlyList<HrLeaverRow> items)
|
||||||
|
=> items
|
||||||
|
.GroupBy(x => x.FluktuationAusschlussgrund ?? "Relevant")
|
||||||
|
.Select(g => new HrKpiGroupValue { Label = g.Key, Count = g.Count(), Value = g.Count() })
|
||||||
|
.OrderByDescending(x => x.Count);
|
||||||
|
|
||||||
|
private RenderFragment<HrTurnoverVisuals> TurnoverGauge => visual => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@visual.RateTitle</MudText>
|
||||||
|
<div class="hr-gauge" style="@($"--gauge-color:{visual.GaugeColor}; --gauge-deg:{visual.GaugeRotationDegrees.ToString("0", System.Globalization.CultureInfo.InvariantCulture)}deg")">
|
||||||
|
<div class="hr-gauge-track"></div>
|
||||||
|
<div class="hr-gauge-needle"></div>
|
||||||
|
<div class="hr-gauge-center">
|
||||||
|
<div class="hr-gauge-value">@visual.YearRateLabel</div>
|
||||||
|
<div class="hr-gauge-caption">0-20%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hr-gauge-scale">
|
||||||
|
<span>0%</span>
|
||||||
|
<span>8%</span>
|
||||||
|
<span>12%</span>
|
||||||
|
<span>20%+</span>
|
||||||
|
</div>
|
||||||
|
</MudPaper>;
|
||||||
|
|
||||||
|
private RenderFragment<IReadOnlyList<HrKpiGroupValue>> TurnoverFunnel => items => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Austritts-Funnel", "Leaver funnel")</MudText>
|
||||||
|
<div class="hr-funnel">
|
||||||
|
@foreach (var item in items)
|
||||||
|
{
|
||||||
|
<div class="hr-funnel-row">
|
||||||
|
<div class="hr-funnel-label">@item.Label</div>
|
||||||
|
<div class="hr-funnel-bar-wrap">
|
||||||
|
<div class="hr-funnel-bar" style="@($"width:{Math.Max(item.Percent, 3).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}%; background:{item.Color}")">
|
||||||
|
<span>@item.Count.ToString("N0")</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</MudPaper>;
|
||||||
|
|
||||||
|
private RenderFragment<IReadOnlyList<HrKpiGroupValue>> TurnoverDonut => items => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Ausschlussgruende", "Exclusion reasons")</MudText>
|
||||||
|
<div class="hr-donut-wrap">
|
||||||
|
<div class="hr-donut" style="@BuildDonutStyle(items)">
|
||||||
|
<div class="hr-donut-hole">@items.Sum(x => x.Count).ToString("N0")</div>
|
||||||
|
</div>
|
||||||
|
<div class="hr-donut-legend">
|
||||||
|
@foreach (var item in items.Take(7))
|
||||||
|
{
|
||||||
|
<div class="hr-legend-row">
|
||||||
|
<span class="hr-legend-dot" style="@($"background:{item.Color}")"></span>
|
||||||
|
<span>@item.Label</span>
|
||||||
|
<strong>@item.Count.ToString("N0")</strong>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MudPaper>;
|
||||||
|
|
||||||
|
private RenderFragment<IReadOnlyList<HrKpiGroupValue>> HorizontalBars => items => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Relevante Austritte nach Organisation", "Relevant leavers by organisation")</MudText>
|
||||||
|
<div class="hr-bars">
|
||||||
|
@foreach (var item in items)
|
||||||
|
{
|
||||||
|
<div class="hr-bar-row">
|
||||||
|
<div class="hr-bar-label">@item.Label</div>
|
||||||
|
<div class="hr-bar-track">
|
||||||
|
<div class="hr-bar-fill" style="@($"width:{Math.Max(item.Percent, 3).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}%; background:{item.Color}")"></div>
|
||||||
|
</div>
|
||||||
|
<div class="hr-bar-value">@item.Count.ToString("N0")</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</MudPaper>;
|
||||||
|
|
||||||
|
private RenderFragment<HrTurnoverVisuals> MonthlyBars => visual => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@visual.TimelineTitle</MudText>
|
||||||
|
<div class="hr-month-bars">
|
||||||
|
@foreach (var item in visual.MonthlyRelevantLeavers)
|
||||||
|
{
|
||||||
|
<div class="hr-month">
|
||||||
|
<div class="hr-month-bar" style="@($"height:{Math.Max(item.Percent, item.Count > 0 ? 8 : 1).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}%; background:{item.Color}")"></div>
|
||||||
|
<div class="hr-month-value">@item.Count</div>
|
||||||
|
<div class="hr-month-label">@item.Label</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</MudPaper>;
|
||||||
|
|
||||||
|
private static string BuildDonutStyle(IReadOnlyList<HrKpiGroupValue> items)
|
||||||
|
{
|
||||||
|
var total = items.Sum(x => x.Count);
|
||||||
|
if (total <= 0)
|
||||||
|
return "background:#e0e0e0";
|
||||||
|
var current = 0m;
|
||||||
|
var segments = new List<string>();
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
var start = current;
|
||||||
|
current += item.Count / (decimal)total * 100m;
|
||||||
|
segments.Add($"{item.Color} {start.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}% {current.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}%");
|
||||||
|
}
|
||||||
|
return $"background:conic-gradient({string.Join(", ", segments)})";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hr-viz-panel {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-print-header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-print-toolbar {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-guide-steps {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(150px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-guide-step {
|
||||||
|
min-height: 175px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--mud-palette-lines-default);
|
||||||
|
border-top: 5px solid var(--mud-palette-primary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--mud-palette-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-guide-step span {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--mud-palette-primary-text);
|
||||||
|
background: var(--mud-palette-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-guide-step p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--mud-palette-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@@media (max-width: 1100px) {
|
||||||
|
.hr-guide-steps {
|
||||||
|
grid-template-columns: repeat(2, minmax(150px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@@media (max-width: 700px) {
|
||||||
|
.hr-guide-steps {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-gauge {
|
||||||
|
--gauge-color: #2e7d32;
|
||||||
|
--gauge-deg: 0deg;
|
||||||
|
position: relative;
|
||||||
|
height: 170px;
|
||||||
|
display: grid;
|
||||||
|
place-items: end center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-gauge-track {
|
||||||
|
width: 260px;
|
||||||
|
height: 130px;
|
||||||
|
border-radius: 260px 260px 0 0;
|
||||||
|
background: conic-gradient(from 270deg at 50% 100%, #2e7d32 0deg 72deg, #f9a825 72deg 108deg, #c62828 108deg 180deg, transparent 180deg 360deg);
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-gauge-track::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 34px;
|
||||||
|
right: 34px;
|
||||||
|
bottom: 0;
|
||||||
|
height: 96px;
|
||||||
|
border-radius: 192px 192px 0 0;
|
||||||
|
background: var(--mud-palette-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-gauge-needle {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 4px;
|
||||||
|
width: 4px;
|
||||||
|
height: 112px;
|
||||||
|
background: #263238;
|
||||||
|
transform-origin: bottom center;
|
||||||
|
transform: rotate(calc(var(--gauge-deg) - 90deg));
|
||||||
|
border-radius: 4px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-gauge-center {
|
||||||
|
z-index: 3;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-gauge-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gauge-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-gauge-caption,
|
||||||
|
.hr-gauge-scale {
|
||||||
|
color: var(--mud-palette-text-secondary);
|
||||||
|
font-size: .8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-gauge-scale {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
max-width: 280px;
|
||||||
|
margin: 4px auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-funnel-row,
|
||||||
|
.hr-bar-row,
|
||||||
|
.hr-legend-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(110px, 1fr) 2fr auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin: 9px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-funnel-bar-wrap,
|
||||||
|
.hr-bar-track {
|
||||||
|
background: rgba(0,0,0,.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
height: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-funnel-bar,
|
||||||
|
.hr-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-right: 8px;
|
||||||
|
min-width: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-donut-wrap {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 150px 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-donut {
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-donut::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--mud-palette-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-donut-hole {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
z-index: 2;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-legend-row {
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
margin: 6px 0;
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-legend-dot {
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-bars {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-bar-row {
|
||||||
|
grid-template-columns: minmax(130px, 1.2fr) 2fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-month-bars {
|
||||||
|
height: 240px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(12, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-month {
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 1fr auto auto;
|
||||||
|
align-items: end;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--mud-palette-text-secondary);
|
||||||
|
font-size: .8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-month-bar {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2px;
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-month-value {
|
||||||
|
color: var(--mud-palette-text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@@media (max-width: 700px) {
|
||||||
|
.hr-donut-wrap {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-funnel-row,
|
||||||
|
.hr-bar-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
@inherits LayoutComponentBase
|
||||||
|
@implements IDisposable
|
||||||
|
@using System.Security.Claims
|
||||||
|
@inject TrafagSalesExporter.Services.IUiTextService UiText
|
||||||
|
|
||||||
|
<MudThemeProvider Theme="_theme" />
|
||||||
|
<MudPopoverProvider />
|
||||||
|
<MudDialogProvider />
|
||||||
|
<MudSnackbarProvider />
|
||||||
|
|
||||||
|
<MudLayout>
|
||||||
|
<MudAppBar Elevation="1" Color="Color.Primary">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start"
|
||||||
|
OnClick="ToggleDrawer" />
|
||||||
|
<MudText Typo="Typo.h6" Class="ml-3 app-title">@T("Trafag Finance/Sales Management Cockpit", "Trafag Finance/Sales Management Cockpit")</MudText>
|
||||||
|
<MudSpacer />
|
||||||
|
<MudMenu Class="mr-3 language-menu"
|
||||||
|
AnchorOrigin="Origin.BottomRight"
|
||||||
|
TransformOrigin="Origin.TopRight"
|
||||||
|
Dense>
|
||||||
|
<ActivatorContent>
|
||||||
|
<MudButton Variant="Variant.Outlined"
|
||||||
|
Color="Color.Inherit"
|
||||||
|
Size="Size.Small"
|
||||||
|
StartIcon="@Icons.Material.Filled.Translate"
|
||||||
|
EndIcon="@Icons.Material.Filled.ExpandMore"
|
||||||
|
Class="language-button">
|
||||||
|
@LanguageLabel
|
||||||
|
</MudButton>
|
||||||
|
</ActivatorContent>
|
||||||
|
<ChildContent>
|
||||||
|
<MudMenuItem OnClick="@(() => ChangeLanguage("de"))">Deutsch</MudMenuItem>
|
||||||
|
<MudMenuItem OnClick="@(() => ChangeLanguage("en"))">English</MudMenuItem>
|
||||||
|
<MudMenuItem OnClick="@(() => ChangeLanguage("es"))">Español</MudMenuItem>
|
||||||
|
<MudMenuItem OnClick="@(() => ChangeLanguage("it"))">Italiano</MudMenuItem>
|
||||||
|
<MudMenuItem OnClick="@(() => ChangeLanguage("hi"))">हिन्दी</MudMenuItem>
|
||||||
|
</ChildContent>
|
||||||
|
</MudMenu>
|
||||||
|
<AuthorizeView>
|
||||||
|
<Authorized Context="authState">
|
||||||
|
<MudText Typo="Typo.caption" Class="mr-3">@ShortName(authState.User)</MudText>
|
||||||
|
</Authorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
<img src="trafag.jpg" alt="Trafag" class="app-logo" />
|
||||||
|
</MudAppBar>
|
||||||
|
|
||||||
|
<MudDrawer @bind-Open="_drawerOpen" Elevation="2" ClipMode="DrawerClipMode.Always">
|
||||||
|
<NavMenu />
|
||||||
|
</MudDrawer>
|
||||||
|
|
||||||
|
<MudMainContent Class="pa-4" @key="UiText.CurrentLanguage">
|
||||||
|
@Body
|
||||||
|
</MudMainContent>
|
||||||
|
</MudLayout>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private bool _drawerOpen = true;
|
||||||
|
|
||||||
|
private readonly MudTheme _theme = new()
|
||||||
|
{
|
||||||
|
PaletteLight = new PaletteLight
|
||||||
|
{
|
||||||
|
Primary = "#B71C1C",
|
||||||
|
Secondary = "#7F1D1D",
|
||||||
|
AppbarBackground = "#B71C1C"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
UiText.Changed += HandleLanguageChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
|
||||||
|
|
||||||
|
private void ChangeLanguage(string language)
|
||||||
|
{
|
||||||
|
UiText.SetLanguage(language);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleLanguageChanged()
|
||||||
|
{
|
||||||
|
InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string LanguageLabel => UiText.CurrentLanguage.ToUpperInvariant();
|
||||||
|
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
|
||||||
|
private static string ShortName(ClaimsPrincipal user)
|
||||||
|
{
|
||||||
|
var name = user.Identity?.Name ?? string.Empty;
|
||||||
|
var separator = name.LastIndexOf('\\');
|
||||||
|
return separator >= 0 && separator < name.Length - 1 ? name[(separator + 1)..] : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
UiText.Changed -= HandleLanguageChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
@using TrafagSalesExporter.Models
|
||||||
|
@using TrafagSalesExporter.Security
|
||||||
|
@using TrafagSalesExporter.Services
|
||||||
|
@implements IDisposable
|
||||||
|
@inject IUiTextService UiText
|
||||||
|
@inject IFinanceCockpitAccessService FinanceAccess
|
||||||
|
@inject INavigationMenuService NavigationMenuService
|
||||||
|
@inject IConfiguration Configuration
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||||
|
@inject IAuthorizationService AuthorizationService
|
||||||
|
|
||||||
|
<MudNavMenu>
|
||||||
|
@foreach (var item in RootItems)
|
||||||
|
{
|
||||||
|
<NavMenuNode Item="item"
|
||||||
|
Items="_visibleItems"
|
||||||
|
HiddenKeys="_hiddenKeys"
|
||||||
|
OnAction="HandleMenuActionAsync" />
|
||||||
|
}
|
||||||
|
</MudNavMenu>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<NavigationMenuItem> _visibleItems = [];
|
||||||
|
private readonly HashSet<string> _hiddenKeys = [];
|
||||||
|
|
||||||
|
private bool ShowFinanceComparison => Configuration.GetValue("Navigation:ShowFinanceComparison", true);
|
||||||
|
|
||||||
|
private IEnumerable<NavigationMenuItem> RootItems => _visibleItems
|
||||||
|
.Where(x => string.IsNullOrWhiteSpace(x.ParentKey))
|
||||||
|
.Where(x => !_hiddenKeys.Contains(x.Key))
|
||||||
|
.OrderBy(x => x.SortOrder)
|
||||||
|
.ThenBy(x => x.TitleDe);
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
UiText.Changed += HandleLanguageChanged;
|
||||||
|
await LoadMenuAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadMenuAsync()
|
||||||
|
{
|
||||||
|
var items = await NavigationMenuService.GetItemsAsync();
|
||||||
|
var authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||||
|
var user = authenticationState.User;
|
||||||
|
var filtered = new List<NavigationMenuItem>();
|
||||||
|
|
||||||
|
foreach (var item in items.Where(x => x.IsVisible))
|
||||||
|
{
|
||||||
|
if (!await IsAuthorizedAsync(user, item))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
filtered.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
_hiddenKeys.Clear();
|
||||||
|
if (!ShowFinanceComparison)
|
||||||
|
_hiddenKeys.Add("finance-comparison");
|
||||||
|
if (!FinanceAccess.IsEnabled || !FinanceAccess.IsUnlocked)
|
||||||
|
_hiddenKeys.Add("finance-lock");
|
||||||
|
|
||||||
|
_visibleItems = filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> IsAuthorizedAsync(System.Security.Claims.ClaimsPrincipal user, NavigationMenuItem item)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(item.RequiredPolicy))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var result = await AuthorizationService.AuthorizeAsync(user, item.RequiredPolicy);
|
||||||
|
return result.Succeeded;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task HandleMenuActionAsync(string key)
|
||||||
|
{
|
||||||
|
if (key == "finance-lock")
|
||||||
|
{
|
||||||
|
FinanceAccess.Lock();
|
||||||
|
Navigation.NavigateTo(string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleLanguageChanged()
|
||||||
|
{
|
||||||
|
InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
UiText.Changed -= HandleLanguageChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
@using TrafagSalesExporter.Models
|
||||||
|
@using TrafagSalesExporter.Services
|
||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
|
|
||||||
|
@if (Item.ItemType == NavigationMenuItemTypes.Group)
|
||||||
|
{
|
||||||
|
<MudNavGroup Title="@Title" Icon="@Icon" Expanded="@Item.IsExpanded">
|
||||||
|
@foreach (var child in Children)
|
||||||
|
{
|
||||||
|
<NavMenuNode Item="child"
|
||||||
|
Items="Items"
|
||||||
|
HiddenKeys="HiddenKeys"
|
||||||
|
OnAction="OnAction" />
|
||||||
|
}
|
||||||
|
</MudNavGroup>
|
||||||
|
}
|
||||||
|
else if (Item.ItemType == NavigationMenuItemTypes.Action)
|
||||||
|
{
|
||||||
|
<MudButton Variant="Variant.Text" Color="Color.Secondary" Size="Size.Small"
|
||||||
|
StartIcon="@Icon" OnClick="() => OnAction.InvokeAsync(Item.Key)" Class="ml-3">
|
||||||
|
@Title
|
||||||
|
</MudButton>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudNavLink Href="@Item.Href" Match="@Match" Icon="@Icon">
|
||||||
|
@Title
|
||||||
|
</MudNavLink>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, EditorRequired] public NavigationMenuItem Item { get; set; } = default!;
|
||||||
|
[Parameter, EditorRequired] public IReadOnlyList<NavigationMenuItem> Items { get; set; } = [];
|
||||||
|
[Parameter] public HashSet<string> HiddenKeys { get; set; } = [];
|
||||||
|
[Parameter] public EventCallback<string> OnAction { get; set; }
|
||||||
|
|
||||||
|
private string Title => UiText.Text(Item.TitleDe, Item.TitleEn);
|
||||||
|
private string Icon => NavigationIconResolver.Resolve(Item.Icon);
|
||||||
|
private NavLinkMatch Match => string.Equals(Item.Match, "All", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? NavLinkMatch.All
|
||||||
|
: NavLinkMatch.Prefix;
|
||||||
|
|
||||||
|
[Inject] private IUiTextService UiText { get; set; } = default!;
|
||||||
|
|
||||||
|
private IEnumerable<NavigationMenuItem> Children => Items
|
||||||
|
.Where(x => x.IsVisible)
|
||||||
|
.Where(x => !HiddenKeys.Contains(x.Key))
|
||||||
|
.Where(x => string.Equals(x.ParentKey, Item.Key, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderBy(x => x.SortOrder)
|
||||||
|
.ThenBy(x => x.TitleDe);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
@page "/admin/sessions"
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
|
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
|
||||||
|
@using TrafagSalesExporter.Services
|
||||||
|
@inject IAccessSessionTracker SessionTracker
|
||||||
|
@inject IAdminAccessService AdminAccess
|
||||||
|
@inject ILandingPageSettingsService LandingSettings
|
||||||
|
@inject IUiTextService UiText
|
||||||
|
|
||||||
|
<PageTitle>@T("Aktive Logins", "Active logins")</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-4">@T("Aktive Logins", "Active logins")</MudText>
|
||||||
|
|
||||||
|
@if (!AdminAccess.IsUnlocked)
|
||||||
|
{
|
||||||
|
<AdminAccessPanel OnUnlocked="Refresh" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
||||||
|
<MudStack Row AlignItems="AlignItems.Center" Class="mb-4">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.h6">@T("Startseite", "Landing page")</MudText>
|
||||||
|
<MudText Typo="Typo.caption">
|
||||||
|
@T("Optionale Animation unter dem Willkommens-Text.", "Optional animation below the welcome text.")
|
||||||
|
</MudText>
|
||||||
|
</div>
|
||||||
|
<MudSpacer />
|
||||||
|
<MudSwitch T="bool" Value="LandingSettings.ShowWalkingLabFigure" ValueChanged="SetWalkingFigure"
|
||||||
|
Color="Color.Primary"
|
||||||
|
Label="@T("Strichmännchen anzeigen", "Show stick figure")" />
|
||||||
|
</MudStack>
|
||||||
|
<MudDivider Class="mb-4" />
|
||||||
|
<MudStack Row AlignItems="AlignItems.Center" Class="mb-3">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.h6">@T("HR-/Finance-Cockpit Sessions", "HR/Finance cockpit sessions")</MudText>
|
||||||
|
<MudText Typo="Typo.caption">
|
||||||
|
@T("Gezählt werden App-interne Entsperrungen seit dem letzten App-Start.", "Counts app-internal unlocks since the last app start.")
|
||||||
|
</MudText>
|
||||||
|
</div>
|
||||||
|
<MudSpacer />
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Lock" OnClick="LockAdmin">
|
||||||
|
@T("Admin sperren", "Lock admin")
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Refresh" OnClick="Refresh">
|
||||||
|
@T("Aktualisieren", "Refresh")
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
|
<MudTable Items="_sessions" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Bereich", "Area")</MudTh>
|
||||||
|
<MudTh>@T("Name", "Name")</MudTh>
|
||||||
|
<MudTh>@T("IP-Adresse", "IP address")</MudTh>
|
||||||
|
<MudTh>@T("Entsperrt seit", "Unlocked since")</MudTh>
|
||||||
|
<MudTh>@T("Zuletzt gesehen", "Last seen")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Area</MudTd>
|
||||||
|
<MudTd>@context.Username</MudTd>
|
||||||
|
<MudTd>@context.RemoteAddress</MudTd>
|
||||||
|
<MudTd>@FormatDate(context.StartedAt)</MudTd>
|
||||||
|
<MudTd>@FormatDate(context.LastSeenAt)</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
<NoRecordsContent>
|
||||||
|
<MudText Typo="Typo.caption">@T("Keine aktiven HR-/Finance-Logins erfasst.", "No active HR/Finance logins recorded.")</MudText>
|
||||||
|
</NoRecordsContent>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined">
|
||||||
|
@T("Hinweis: HR und Finance verwenden gemeinsame App-Logins. Diese Seite zeigt daher den verwendeten Login-Namen und die Session, nicht zwingend die echte Person.", "Note: HR and Finance use shared app logins. This page therefore shows the used login name and session, not necessarily the real person.")
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private IReadOnlyList<AccessSessionSnapshot> _sessions = [];
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Refresh()
|
||||||
|
{
|
||||||
|
_sessions = SessionTracker.GetActiveSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LockAdmin()
|
||||||
|
{
|
||||||
|
AdminAccess.Lock();
|
||||||
|
_sessions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetWalkingFigure(bool value)
|
||||||
|
{
|
||||||
|
LandingSettings.SetShowWalkingLabFigure(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatDate(DateTimeOffset value)
|
||||||
|
=> value.ToString("dd.MM.yyyy HH:mm:ss");
|
||||||
|
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
@page "/"
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
|
@using TrafagSalesExporter.Services
|
||||||
|
@inject IUiTextService UiText
|
||||||
|
@inject ILandingPageSettingsService LandingSettings
|
||||||
|
|
||||||
|
<PageTitle>@T("Trafag Cockpit", "Trafag Cockpit")</PageTitle>
|
||||||
|
|
||||||
|
<div class="home-shell">
|
||||||
|
<div class="home-content">
|
||||||
|
<svg class="home-manometer" viewBox="0 0 600 340" role="img" aria-label="Trafag cockpit manometer">
|
||||||
|
<rect x="0" y="0" width="600" height="340" fill="#fff" />
|
||||||
|
<path d="M70 260 A230 230 0 0 1 530 260" class="gauge-outer" />
|
||||||
|
<path d="M115 260 A185 185 0 0 1 485 260" class="gauge-inner" />
|
||||||
|
|
||||||
|
<line x1="126" y1="260" x2="95" y2="260" class="gauge-tick" />
|
||||||
|
<line x1="177" y1="137" x2="155" y2="115" class="gauge-tick" />
|
||||||
|
<line x1="300" y1="86" x2="300" y2="55" class="gauge-tick" />
|
||||||
|
<line x1="423" y1="137" x2="445" y2="115" class="gauge-tick" />
|
||||||
|
<line x1="474" y1="260" x2="505" y2="260" class="gauge-tick" />
|
||||||
|
|
||||||
|
<text x="150" y="230" class="gauge-label">0</text>
|
||||||
|
<text x="205" y="154" class="gauge-label">25</text>
|
||||||
|
<text x="300" y="126" class="gauge-label">50</text>
|
||||||
|
<text x="395" y="154" class="gauge-label">75</text>
|
||||||
|
<text x="450" y="230" class="gauge-label">100</text>
|
||||||
|
<text x="300" y="222" class="gauge-brand">TRAFAG</text>
|
||||||
|
|
||||||
|
<g class="gauge-needle">
|
||||||
|
<line x1="300" y1="260" x2="300" y2="96" class="needle-line" />
|
||||||
|
</g>
|
||||||
|
<circle cx="300" cy="260" r="28" fill="#050505" />
|
||||||
|
</svg>
|
||||||
|
<div class="home-welcome">@T("Willkommen im Trafag Analyse Dashboard", "Welcome to the Trafag Analytical Dashboard")</div>
|
||||||
|
@if (LandingSettings.ShowWalkingLabFigure)
|
||||||
|
{
|
||||||
|
<div class="walking-stage" aria-label="Walking lab figure">
|
||||||
|
<div class="walking-person">
|
||||||
|
<span class="head"></span>
|
||||||
|
<span class="body"></span>
|
||||||
|
<span class="coat coat-left"></span>
|
||||||
|
<span class="coat coat-right"></span>
|
||||||
|
<span class="arm arm-left"></span>
|
||||||
|
<span class="arm arm-right"></span>
|
||||||
|
<span class="leg leg-left"></span>
|
||||||
|
<span class="leg leg-right"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.home-shell {
|
||||||
|
min-height: calc(100vh - 112px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-manometer {
|
||||||
|
width: min(336px, 58vw);
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-welcome {
|
||||||
|
color: #050505;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.walking-stage {
|
||||||
|
width: min(360px, 70vw);
|
||||||
|
height: 96px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 2px solid #050505;
|
||||||
|
}
|
||||||
|
|
||||||
|
.walking-person {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 4px;
|
||||||
|
width: 48px;
|
||||||
|
height: 82px;
|
||||||
|
animation: lab-walk-path 7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.walking-person span {
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
background: #050505;
|
||||||
|
}
|
||||||
|
|
||||||
|
.walking-person .head {
|
||||||
|
left: 15px;
|
||||||
|
top: 0;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 3px solid #050505;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.walking-person .body {
|
||||||
|
left: 22px;
|
||||||
|
top: 22px;
|
||||||
|
width: 4px;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.walking-person .coat {
|
||||||
|
top: 25px;
|
||||||
|
width: 17px;
|
||||||
|
height: 36px;
|
||||||
|
border: 3px solid #050505;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.walking-person .coat-left {
|
||||||
|
left: 8px;
|
||||||
|
transform: skewY(10deg);
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.walking-person .coat-right {
|
||||||
|
left: 23px;
|
||||||
|
transform: skewY(-10deg);
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.walking-person .arm,
|
||||||
|
.walking-person .leg {
|
||||||
|
width: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transform-origin: 50% 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.walking-person .arm {
|
||||||
|
top: 28px;
|
||||||
|
height: 28px;
|
||||||
|
animation: limb-swing 0.72s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.walking-person .arm-left {
|
||||||
|
left: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.walking-person .arm-right {
|
||||||
|
left: 31px;
|
||||||
|
animation-direction: alternate-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.walking-person .leg {
|
||||||
|
top: 56px;
|
||||||
|
height: 28px;
|
||||||
|
animation: limb-swing 0.72s ease-in-out infinite alternate-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.walking-person .leg-left {
|
||||||
|
left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.walking-person .leg-right {
|
||||||
|
left: 27px;
|
||||||
|
animation-direction: alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@@keyframes lab-walk-path {
|
||||||
|
0% { transform: translateX(-54px); }
|
||||||
|
100% { transform: translateX(calc(min(360px, 70vw) + 54px)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@@keyframes limb-swing {
|
||||||
|
0% { transform: rotate(-24deg); }
|
||||||
|
100% { transform: rotate(24deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-outer,
|
||||||
|
.gauge-inner,
|
||||||
|
.gauge-tick,
|
||||||
|
.needle-line {
|
||||||
|
fill: none;
|
||||||
|
stroke: #050505;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-outer {
|
||||||
|
stroke-width: 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-inner {
|
||||||
|
stroke-width: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-tick {
|
||||||
|
stroke-width: 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-label {
|
||||||
|
fill: #050505;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-anchor: middle;
|
||||||
|
dominant-baseline: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-brand {
|
||||||
|
fill: #050505;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
text-anchor: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.needle-line {
|
||||||
|
stroke-width: 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-needle {
|
||||||
|
transform-origin: 300px 260px;
|
||||||
|
animation: home-gauge-sweep 6.2s infinite cubic-bezier(.42, 0, .2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@@keyframes home-gauge-sweep {
|
||||||
|
0% { transform: rotate(-58deg); }
|
||||||
|
9% { transform: rotate(-12deg); }
|
||||||
|
18% { transform: rotate(43deg); }
|
||||||
|
31% { transform: rotate(8deg); }
|
||||||
|
44% { transform: rotate(68deg); }
|
||||||
|
58% { transform: rotate(-35deg); }
|
||||||
|
72% { transform: rotate(24deg); }
|
||||||
|
86% { transform: rotate(56deg); }
|
||||||
|
100% { transform: rotate(-58deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
}
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
@page "/export-dashboard"
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
|
@using System.Diagnostics
|
||||||
|
@using TrafagSalesExporter.Services
|
||||||
|
@inject IDashboardPageService DashboardPageActions
|
||||||
|
@inject ExportOrchestrationService Orchestrator
|
||||||
|
@inject TimerBackgroundService TimerService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IUiTextService UiText
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
|
<PageTitle>@T("Export Dashboard", "Export dashboard")</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-4">@T("Export Dashboard", "Export dashboard")</MudText>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
||||||
|
<div class="dashboard-header">
|
||||||
|
<MudStack Row AlignItems="AlignItems.Center" Spacing="4" Class="dashboard-actions">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.PlayArrow"
|
||||||
|
OnClick="ExportAll" Disabled="_anyRunning">
|
||||||
|
@T("Alle exportieren", "Export all")
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.TableView"
|
||||||
|
OnClick="ExportConsolidatedOnly" Disabled="_anyRunning">
|
||||||
|
@T("Zentrale Datei neu erzeugen", "Rebuild consolidated file")
|
||||||
|
</MudButton>
|
||||||
|
<MudText Typo="Typo.body1">
|
||||||
|
@if (TimerService.NextRun < DateTime.MaxValue)
|
||||||
|
{
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Schedule" Size="Size.Small" Class="mr-1" />
|
||||||
|
@(string.Format(T("Naechster automatischer Lauf: {0}", "Next automatic run: {0}"), TimerService.NextRun.ToString("dd.MM.yyyy HH:mm")))
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.TimerOff" Size="Size.Small" Class="mr-1" />
|
||||||
|
@T("Timer deaktiviert", "Timer disabled")
|
||||||
|
}
|
||||||
|
</MudText>
|
||||||
|
</MudStack>
|
||||||
|
<div class="dashboard-manometer">
|
||||||
|
<svg class="manometer-svg" viewBox="0 0 210 118" role="img" aria-label="Export activity manometer">
|
||||||
|
<path class="manometer-outer" d="M25 98 A80 80 0 0 1 185 98" />
|
||||||
|
<path class="manometer-inner" d="M47 98 A58 58 0 0 1 163 98" />
|
||||||
|
<line class="manometer-tick" x1="38" y1="98" x2="56" y2="98" />
|
||||||
|
<line class="manometer-tick" x1="61" y1="54" x2="74" y2="67" />
|
||||||
|
<line class="manometer-tick" x1="105" y1="34" x2="105" y2="52" />
|
||||||
|
<line class="manometer-tick" x1="149" y1="54" x2="136" y2="67" />
|
||||||
|
<line class="manometer-tick" x1="172" y1="98" x2="154" y2="98" />
|
||||||
|
<text class="manometer-label" x="43" y="89">0</text>
|
||||||
|
<text class="manometer-label" x="67" y="53">25</text>
|
||||||
|
<text class="manometer-label" x="105" y="28">50</text>
|
||||||
|
<text class="manometer-label" x="143" y="53">75</text>
|
||||||
|
<text class="manometer-label" x="167" y="89">100</text>
|
||||||
|
<text class="manometer-caption" x="105" y="113">EXPORT</text>
|
||||||
|
<g class="manometer-needle">
|
||||||
|
<line class="needle-line" x1="105" y1="98" x2="105" y2="38" />
|
||||||
|
</g>
|
||||||
|
<circle class="manometer-hub" cx="105" cy="98" r="11" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@if (_readinessWarnings.Count > 0)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense Class="mb-4">
|
||||||
|
<MudText Typo="Typo.body2">@T("Aktive Standorte sind noch nicht vollstaendig bereit:", "Active sites are not fully ready:")</MudText>
|
||||||
|
@foreach (var warning in _readinessWarnings)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption">@warning</MudText>
|
||||||
|
}
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_consolidatedStale)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense Class="mb-4">
|
||||||
|
@T("Seit der letzten zentralen Excel wurde mindestens ein Standort neu exportiert. Bitte `Zentrale Datei neu erzeugen` ausfuehren, damit das Endexcel aktuell ist.",
|
||||||
|
"At least one site was exported after the last consolidated Excel. Please rebuild the consolidated file so the final Excel is current.")
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudTable Items="_dashboardRows" Dense Hover Striped Loading="_loading">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Land", "Country")</MudTh>
|
||||||
|
<MudTh>@T("Basis", "Basis")</MudTh>
|
||||||
|
<MudTh>TSC</MudTh>
|
||||||
|
<MudTh>@T("Schema", "Schema")</MudTh>
|
||||||
|
<MudTh>@T("Server", "Server")</MudTh>
|
||||||
|
<MudTh>@T("Status", "Status")</MudTh>
|
||||||
|
<MudTh>@T("Live-Status", "Live status")</MudTh>
|
||||||
|
<MudTh>@T("Zeilen", "Rows")</MudTh>
|
||||||
|
<MudTh>@T("Letzter Lauf", "Last run")</MudTh>
|
||||||
|
<MudTh>@T("Dauer", "Duration")</MudTh>
|
||||||
|
<MudTh>@T("Aktion", "Action")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Land</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudTooltip Text="@context.DataBasis">
|
||||||
|
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
|
||||||
|
<MudIcon Icon="@GetDataBasisIcon(context.DataBasis)" Color="@GetDataBasisColor(context.DataBasis)" Size="Size.Small" />
|
||||||
|
<MudText Typo="Typo.caption">@context.DataBasis</MudText>
|
||||||
|
</MudStack>
|
||||||
|
</MudTooltip>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>@context.TSC</MudTd>
|
||||||
|
<MudTd>@context.Schema</MudTd>
|
||||||
|
<MudTd>@context.ServerName</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@if (Orchestrator.IsExporting(context.SiteId))
|
||||||
|
{
|
||||||
|
<MudProgressCircular Size="Size.Small" Indeterminate Color="Color.Primary" Class="mr-1" />
|
||||||
|
<MudText Typo="Typo.caption">@Orchestrator.GetExportStatus(context.SiteId)</MudText>
|
||||||
|
}
|
||||||
|
else if (context.LastStatus == "OK")
|
||||||
|
{
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
||||||
|
}
|
||||||
|
else if (context.LastStatus == "Error")
|
||||||
|
{
|
||||||
|
<MudTooltip Text="@context.ErrorMessage">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Error" Color="Color.Error" Size="Size.Small" />
|
||||||
|
</MudTooltip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(context.LiveMessage))
|
||||||
|
{
|
||||||
|
<MudTooltip Text="@context.LiveDetails">
|
||||||
|
<MudText Typo="Typo.caption" Style="max-width:360px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block;">
|
||||||
|
@context.LiveMessage
|
||||||
|
</MudText>
|
||||||
|
</MudTooltip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>@(context.RowCount > 0 ? context.RowCount.ToString("N0") : "-")</MudTd>
|
||||||
|
<MudTd>@(context.LastRun.HasValue ? context.LastRun.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-")</MudTd>
|
||||||
|
<MudTd>@(context.DurationSeconds > 0 ? $"{context.DurationSeconds:F1}s" : "-")</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudStack Row Spacing="1">
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.FileDownload"
|
||||||
|
OnClick="() => ExportSingle(context.SiteId)"
|
||||||
|
Disabled="Orchestrator.IsExporting(context.SiteId)">
|
||||||
|
Export
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Info"
|
||||||
|
StartIcon="@Icons.Material.Filled.OpenInNew"
|
||||||
|
OnClick="() => OpenExportFile(context)"
|
||||||
|
Disabled="@(!context.HasOpenableFile || Orchestrator.IsExporting(context.SiteId))">
|
||||||
|
@T("Excel oeffnen", "Open Excel")
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-3">@T("Zentrale Datei", "Consolidated file")</MudText>
|
||||||
|
<MudTable Items="_consolidatedRows" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Datei", "File")</MudTh>
|
||||||
|
<MudTh>Pfad</MudTh>
|
||||||
|
<MudTh>Letzte Änderung</MudTh>
|
||||||
|
<MudTh>@T("Status", "Status")</MudTh>
|
||||||
|
<MudTh>@T("Aktion", "Action")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Label</MudTd>
|
||||||
|
<MudTd>@context.DisplayPath</MudTd>
|
||||||
|
<MudTd>@(context.LastModified.HasValue ? context.LastModified.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-")</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@if (Orchestrator.IsConsolidatedExporting())
|
||||||
|
{
|
||||||
|
<MudProgressCircular Size="Size.Small" Indeterminate Color="Color.Primary" Class="mr-1" />
|
||||||
|
<MudText Typo="Typo.caption">@Orchestrator.GetConsolidatedExportStatus()</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Info"
|
||||||
|
StartIcon="@Icons.Material.Filled.OpenInNew"
|
||||||
|
OnClick="() => OpenFile(context.FilePath)"
|
||||||
|
Disabled="@(!context.HasOpenableFile)">
|
||||||
|
@T("Excel oeffnen", "Open Excel")
|
||||||
|
</MudButton>
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
<NoRecordsContent>
|
||||||
|
<MudText Typo="Typo.caption">@T("Keine zentrale Excel-Datei gefunden.", "No consolidated Excel file found.")</MudText>
|
||||||
|
</NoRecordsContent>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dashboard-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 220px;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-actions {
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-manometer {
|
||||||
|
justify-self: end;
|
||||||
|
width: 210px;
|
||||||
|
height: 118px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #111;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manometer-svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manometer-outer,
|
||||||
|
.manometer-inner {
|
||||||
|
fill: none;
|
||||||
|
stroke: #111;
|
||||||
|
stroke-linecap: square;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manometer-outer {
|
||||||
|
stroke-width: 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manometer-inner {
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manometer-tick,
|
||||||
|
.needle-line {
|
||||||
|
stroke: #111;
|
||||||
|
stroke-width: 3;
|
||||||
|
stroke-linecap: square;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manometer-needle {
|
||||||
|
transform-box: view-box;
|
||||||
|
transform-origin: 105px 98px;
|
||||||
|
animation: manometer-sweep 5.8s infinite cubic-bezier(.45, 0, .25, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.manometer-hub {
|
||||||
|
fill: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manometer-label,
|
||||||
|
.manometer-caption {
|
||||||
|
fill: #111;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
text-anchor: middle;
|
||||||
|
dominant-baseline: middle;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manometer-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manometer-caption {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@@keyframes manometer-sweep {
|
||||||
|
0% { transform: rotate(-52deg); }
|
||||||
|
11% { transform: rotate(18deg); }
|
||||||
|
19% { transform: rotate(-8deg); }
|
||||||
|
33% { transform: rotate(63deg); }
|
||||||
|
48% { transform: rotate(4deg); }
|
||||||
|
61% { transform: rotate(38deg); }
|
||||||
|
74% { transform: rotate(-41deg); }
|
||||||
|
88% { transform: rotate(55deg); }
|
||||||
|
100% { transform: rotate(-52deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@@media (max-width: 900px) {
|
||||||
|
.dashboard-header {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-manometer {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<DashboardRow> _dashboardRows = new();
|
||||||
|
private List<ConsolidatedDashboardRow> _consolidatedRows = new();
|
||||||
|
private List<string> _readinessWarnings = new();
|
||||||
|
private bool _consolidatedStale;
|
||||||
|
private bool _loading = true;
|
||||||
|
private bool _anyRunning;
|
||||||
|
private CancellationTokenSource? _pollingCts;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
Orchestrator.OnExportStatusChanged += HandleStatusChanged;
|
||||||
|
await LoadDataAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadDataAsync()
|
||||||
|
{
|
||||||
|
_loading = true;
|
||||||
|
var state = await DashboardPageActions.LoadAsync();
|
||||||
|
_dashboardRows = state.DashboardRows;
|
||||||
|
_consolidatedRows = state.ConsolidatedRows;
|
||||||
|
_readinessWarnings = state.ReadinessWarnings;
|
||||||
|
_consolidatedStale = state.IsConsolidatedStale;
|
||||||
|
|
||||||
|
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting();
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExportAll()
|
||||||
|
{
|
||||||
|
if (_readinessWarnings.Count > 0)
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Es gibt aktive Standorte mit fehlender manueller Datei. Bitte Warnung im Dashboard pruefen.",
|
||||||
|
"There are active sites with missing manual files. Please check the dashboard warning."), Severity.Warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
_anyRunning = true;
|
||||||
|
await LoadDataAsync();
|
||||||
|
StartPolling();
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Orchestrator.ExportAllAsync();
|
||||||
|
await InvokeAsync(() =>
|
||||||
|
Snackbar.Add(T("Export fuer alle Standorte beendet", "Export completed for all sites"), Severity.Success));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await InvokeAsync(() =>
|
||||||
|
Snackbar.Add(string.Format(T("Export fuer alle Standorte fehlgeschlagen: {0}", "Export for all sites failed: {0}"), FormatException(ex)), Severity.Error));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
await LoadDataAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Snackbar.Add(T("Export fuer alle Standorte gestartet", "Export started for all sites"), Severity.Info);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExportConsolidatedOnly()
|
||||||
|
{
|
||||||
|
_anyRunning = true;
|
||||||
|
await LoadDataAsync();
|
||||||
|
StartPolling();
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var filePath = await Orchestrator.ExportConsolidatedOnlyAsync();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(filePath))
|
||||||
|
{
|
||||||
|
await InvokeAsync(() =>
|
||||||
|
Snackbar.Add(string.Format(T("Zentrale Datei erzeugt: {0}", "Consolidated file created: {0}"), filePath), Severity.Success));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await InvokeAsync(() =>
|
||||||
|
Snackbar.Add(T("Zentrale Datei konnte nicht erzeugt werden. Details stehen in den Logs.", "Consolidated file could not be created. Details are in the logs."), Severity.Warning));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await InvokeAsync(() =>
|
||||||
|
Snackbar.Add(string.Format(T("Zentrale Datei fehlgeschlagen: {0}", "Consolidated file failed: {0}"), FormatException(ex)), Severity.Error));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
await LoadDataAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Snackbar.Add(T("Zentrale Datei wird erzeugt", "Building consolidated file"), Severity.Info);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ExportSingle(int siteId)
|
||||||
|
{
|
||||||
|
_anyRunning = true;
|
||||||
|
_ = InvokeAsync(async () => await LoadDataAsync());
|
||||||
|
StartPolling();
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await Orchestrator.ExportSiteByIdAsync(siteId);
|
||||||
|
|
||||||
|
if (result?.Log.Status == "OK" && !string.IsNullOrWhiteSpace(result.FilePath))
|
||||||
|
{
|
||||||
|
await InvokeAsync(() =>
|
||||||
|
Snackbar.Add(string.Format(T("Export gespeichert: {0}", "Export saved: {0}"), result.FilePath), Severity.Success));
|
||||||
|
await InvokeAsync(() =>
|
||||||
|
Snackbar.Add(T("Die zentrale Excel ist danach noch nicht automatisch aktualisiert. Bitte `Zentrale Datei neu erzeugen` starten.",
|
||||||
|
"The consolidated Excel is not automatically updated after this. Please rebuild the consolidated file."), Severity.Info));
|
||||||
|
}
|
||||||
|
else if (result?.Log.Status == "Error" && !string.IsNullOrWhiteSpace(result.Log.ErrorMessage))
|
||||||
|
{
|
||||||
|
await InvokeAsync(() =>
|
||||||
|
Snackbar.Add(string.Format(T("Export fehlgeschlagen: {0}", "Export failed: {0}"), result.Log.ErrorMessage), Severity.Error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await InvokeAsync(() =>
|
||||||
|
Snackbar.Add(string.Format(T("Export fehlgeschlagen: {0}", "Export failed: {0}"), FormatException(ex)), Severity.Error));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
await LoadDataAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Snackbar.Add(T("Export gestartet", "Export started"), Severity.Info);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void HandleStatusChanged()
|
||||||
|
{
|
||||||
|
await InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting() || _dashboardRows.Count == 0;
|
||||||
|
if (_anyRunning)
|
||||||
|
{
|
||||||
|
StartPolling();
|
||||||
|
await RefreshLiveDataAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
StopPolling();
|
||||||
|
await LoadDataAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
StopPolling();
|
||||||
|
Orchestrator.OnExportStatusChanged -= HandleStatusChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenExportFile(DashboardRow row)
|
||||||
|
{
|
||||||
|
OpenFile(row.FilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenFile(string filePath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Exportdatei nicht gefunden.", "Export file not found."), Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = filePath,
|
||||||
|
UseShellExecute = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add(string.Format(T("Datei konnte nicht geoeffnet werden: {0}", "Could not open file: {0}"), ex.Message), Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartPolling()
|
||||||
|
{
|
||||||
|
if (_pollingCts is not null && !_pollingCts.IsCancellationRequested)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_pollingCts = new CancellationTokenSource();
|
||||||
|
_ = PollDashboardAsync(_pollingCts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopPolling()
|
||||||
|
{
|
||||||
|
_pollingCts?.Cancel();
|
||||||
|
_pollingCts?.Dispose();
|
||||||
|
_pollingCts = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PollDashboardAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (await timer.WaitForNextTickAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
var anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting();
|
||||||
|
if (!anyRunning)
|
||||||
|
{
|
||||||
|
await InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
_anyRunning = false;
|
||||||
|
await LoadDataAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
StopPolling();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
_anyRunning = true;
|
||||||
|
await RefreshLiveDataAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task RefreshLiveDataAsync()
|
||||||
|
{
|
||||||
|
foreach (var row in _dashboardRows)
|
||||||
|
{
|
||||||
|
if (!Orchestrator.IsExporting(row.SiteId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
row.LiveMessage = Orchestrator.GetExportStatus(row.SiteId);
|
||||||
|
row.LiveDetails = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatException(Exception ex)
|
||||||
|
=> ex.InnerException is null ? ex.Message : $"{ex.Message} Inner: {ex.InnerException.Message}";
|
||||||
|
|
||||||
|
private static string GetDataBasisIcon(string dataBasis)
|
||||||
|
{
|
||||||
|
if (dataBasis.Contains("Excel", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Icons.Material.Filled.TableView;
|
||||||
|
if (dataBasis.Contains("CSV", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
dataBasis.Contains("Datei", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Icons.Material.Filled.Description;
|
||||||
|
if (dataBasis.Contains("SAP", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Icons.Material.Filled.CloudSync;
|
||||||
|
if (dataBasis.Contains("Server", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Icons.Material.Filled.Storage;
|
||||||
|
|
||||||
|
return Icons.Material.Filled.Source;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color GetDataBasisColor(string dataBasis)
|
||||||
|
{
|
||||||
|
if (dataBasis.Contains("Excel", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Color.Success;
|
||||||
|
if (dataBasis.Contains("CSV", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
dataBasis.Contains("Datei", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Color.Info;
|
||||||
|
if (dataBasis.Contains("SAP", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Color.Primary;
|
||||||
|
if (dataBasis.Contains("Server", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Color.Secondary;
|
||||||
|
|
||||||
|
return Color.Default;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
@page "/finance-cockpit/vergleich"
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
|
@using TrafagSalesExporter.Models
|
||||||
|
@using TrafagSalesExporter.Services
|
||||||
|
@inject IFinanceReconciliationService FinanceReconciliationService
|
||||||
|
@inject IUiTextService UiText
|
||||||
|
|
||||||
|
<PageTitle>@T("Soll/Ist Vergleich", "Actual/reference comparison")</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-4">@T("Soll/Ist Vergleich", "Actual/reference comparison")</MudText>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
||||||
|
<MudStack Row AlignItems="AlignItems.Center" Class="mb-3">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.h6">@T("Net Sales Actuals 2025 Referenz", "Net sales actuals 2025 reference")</MudText>
|
||||||
|
<MudText Typo="Typo.caption">@T("Verbindliche Finance-Sicht aus der aktuellen zentralen Datenquelle", "Authoritative finance view from the current central data source")</MudText>
|
||||||
|
</div>
|
||||||
|
<MudSpacer />
|
||||||
|
<MudButton Variant="@(_hideRowsWithoutActual ? Variant.Filled : Variant.Outlined)"
|
||||||
|
Color="Color.Primary"
|
||||||
|
Size="Size.Small"
|
||||||
|
StartIcon="@Icons.Material.Filled.FilterAlt"
|
||||||
|
OnClick="ToggleActualFilter">
|
||||||
|
@T("Ohne Ist", "Without empty actuals")
|
||||||
|
</MudButton>
|
||||||
|
<MudText Typo="Typo.caption">
|
||||||
|
@string.Format(T("{0:N0}/{1:N0} Zeilen", "{0:N0}/{1:N0} rows"), FilteredNetSalesReferenceRows.Count, _netSalesReferenceRows.Count)
|
||||||
|
</MudText>
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Refresh"
|
||||||
|
OnClick="LoadAsync" Disabled="_loading">
|
||||||
|
@(_loading ? T("Lade...", "Loading...") : T("Aktualisieren", "Refresh"))
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
|
<MudTable Items="FilteredNetSalesReferenceRows" Dense Hover Striped Loading="_loading">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Ampel", "Status")</MudTh>
|
||||||
|
<MudTh>@T("Land", "Country")</MudTh>
|
||||||
|
<MudTh>@T("Ist 2025", "Actual 2025")</MudTh>
|
||||||
|
<MudTh>@T("Referenz", "Reference")</MudTh>
|
||||||
|
<MudTh>@T("Differenz", "Difference")</MudTh>
|
||||||
|
<MudTh>@T("Waehrung", "Currency")</MudTh>
|
||||||
|
<MudTh>@T("Berechnung", "Calculation")</MudTh>
|
||||||
|
<MudTh>@T("Zeilen", "Rows")</MudTh>
|
||||||
|
<MudTh>@T("Varianten", "Variants")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="@StatusColor(context.Status)" Variant="Variant.Filled">
|
||||||
|
@StatusText(context.Status)
|
||||||
|
</MudChip>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudText Typo="Typo.body2">@context.Label</MudText>
|
||||||
|
<MudText Typo="Typo.caption">@context.Key</MudText>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>@FormatAmount(context.ActualValue)</MudTd>
|
||||||
|
<MudTd>@FormatAmount(context.ReferenceValue)</MudTd>
|
||||||
|
<MudTd>@FormatAmount(context.Difference)</MudTd>
|
||||||
|
<MudTd>@FormatCurrency(context)</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudText Typo="Typo.body2">@(string.IsNullOrWhiteSpace(context.ValueField) ? "-" : context.ValueField)</MudText>
|
||||||
|
<MudText Typo="Typo.caption">@BuildCalculationHint(context)</MudText>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>@(context.RowCount > 0 ? context.RowCount.ToString("N0") : "-")</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@if (context.Candidates.Count > 0)
|
||||||
|
{
|
||||||
|
<details>
|
||||||
|
<summary>@context.Candidates.Count @T("Varianten anzeigen", "show variants")</summary>
|
||||||
|
<table class="finance-variant-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>@T("Abgrenzung", "Scope")</th>
|
||||||
|
<th>@T("Waehrung", "Currency")</th>
|
||||||
|
<th>@T("Wert", "Value")</th>
|
||||||
|
<th>@T("Diff.", "Diff.")</th>
|
||||||
|
<th>@T("IC", "IC")</th>
|
||||||
|
<th>@T("Diff ohne IC", "Diff excl. IC")</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var candidate in context.Candidates)
|
||||||
|
{
|
||||||
|
<tr class="@(candidate.IsPreferred ? "preferred-variant" : string.Empty)">
|
||||||
|
<td>@candidate.Label</td>
|
||||||
|
<td>@candidate.Currency</td>
|
||||||
|
<td class="num">@FormatAmount(candidate.Value)</td>
|
||||||
|
<td class="num">@FormatAmount(candidate.Difference)</td>
|
||||||
|
<td class="num">@FormatAmount(candidate.IntercompanyValue)</td>
|
||||||
|
<td class="num">@FormatAmount(candidate.DifferenceExcludingIntercompany)</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>-</span>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
<NoRecordsContent>
|
||||||
|
<MudText Typo="Typo.caption">@T("Keine Referenzdaten fuer aktive Standorte gefunden.", "No reference data found for active sites.")</MudText>
|
||||||
|
</NoRecordsContent>
|
||||||
|
</MudTable>
|
||||||
|
|
||||||
|
<MudAlert Severity="Severity.Info" Dense Variant="Variant.Outlined" Class="mt-3">
|
||||||
|
@T("Diese Seite nutzt dieselbe FinanceReconciliationService-Logik wie das lokale Testprogramm. Vergleich: Jahr 2025 aus Buchungsdatum, sonst Invoice Date, sonst Extraction Date. Das Summenfeld wird automatisch aus Sales Price/Value, DocTotalFC - VatSumFC oder DocTotal - VatSum gewaehlt; Belegkopfwerte werden pro DocEntry nur einmal gezaehlt. IC-Abzug ist eine Diagnose fuer den aktuellen Abgleich und veraendert die Originaldaten nicht.", "This page uses the same FinanceReconciliationService logic as the local test program. Comparison: year 2025 from posting date, otherwise invoice date, otherwise extraction date. The value field is selected automatically from Sales Price/Value, DocTotalFC - VatSumFC, or DocTotal - VatSum; document header values are counted only once per DocEntry. IC deduction is a diagnostic value for the current reconciliation and does not change the original data.")
|
||||||
|
</MudAlert>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.finance-variant-table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 8px;
|
||||||
|
min-width: 720px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-variant-table th,
|
||||||
|
.finance-variant-table td {
|
||||||
|
border: 1px solid var(--mud-palette-lines-default);
|
||||||
|
padding: 4px 6px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-variant-table th {
|
||||||
|
background: var(--mud-palette-background-grey);
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-variant-table .num {
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preferred-variant {
|
||||||
|
background: rgba(33, 150, 243, 0.08);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<NetSalesReferenceRow> _netSalesReferenceRows = new();
|
||||||
|
private bool _hideRowsWithoutActual = true;
|
||||||
|
private bool _loading = true;
|
||||||
|
|
||||||
|
private List<NetSalesReferenceRow> FilteredNetSalesReferenceRows
|
||||||
|
=> _hideRowsWithoutActual
|
||||||
|
? _netSalesReferenceRows.Where(row => row.ActualValue.HasValue).ToList()
|
||||||
|
: _netSalesReferenceRows;
|
||||||
|
|
||||||
|
private void ToggleActualFilter()
|
||||||
|
{
|
||||||
|
_hideRowsWithoutActual = !_hideRowsWithoutActual;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
_loading = true;
|
||||||
|
_netSalesReferenceRows = await FinanceReconciliationService.BuildNetSalesReferenceRowsAsync(2025);
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatAmount(decimal? value)
|
||||||
|
=> value.HasValue ? value.Value.ToString("N2") : "-";
|
||||||
|
|
||||||
|
private static string FormatCurrency(NetSalesReferenceRow row)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(row.ActualCurrency))
|
||||||
|
return row.ReferenceCurrency == "LC"
|
||||||
|
? $"{row.ActualCurrency} / Soll LC"
|
||||||
|
: row.ActualCurrency;
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(row.Currencies) ? "-" : row.Currencies;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildCalculationHint(NetSalesReferenceRow row)
|
||||||
|
{
|
||||||
|
if (row.Key.Equals("UK", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return T("Sage Netto in GBP; Credit Notes negativ; Soll ist Local Currency.", "Sage net in GBP; credit notes negative; reference is local currency.");
|
||||||
|
if (row.Key.Equals("ES", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return T("Sage ImporteNeto; REC/Credit Notes negativ; Zuschlaege/Nebenkosten noch pruefen.", "Sage ImporteNeto; REC/credit notes negative; surcharges/charges still to check.");
|
||||||
|
if (row.Key.Equals("IT", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return T("Bestaetigte IT-Regel: Trafag Italia ausgeschlossen; doppelte Zeilen ohne Supplier country nur einmal.", "Confirmed IT rule: Trafag Italia excluded; duplicate rows without supplier country counted once.");
|
||||||
|
if (row.Key.Equals("DE", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return T("Alphaplan Excel; Finance-Regeln gemäss Deutschland-Rueckmeldung: Weiterberechnungen ausgeschlossen, GS negativ, GS2510095 2024.", "Alphaplan Excel; finance rules per Germany response: recharges excluded, credit notes negative, GS2510095 in 2024.");
|
||||||
|
if (row.Key.Equals("FR", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
row.Key.Equals("IN", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
row.Key.Equals("US", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return T("Passt gegen Soll; Sales Price/Value ist bevorzugte Variante.", "Matches reference; Sales Price/Value is the preferred variant.");
|
||||||
|
|
||||||
|
return row.ReferenceCurrency == "LC"
|
||||||
|
? T("Vergleich gegen Local Currency Referenz.", "Compared against local currency reference.")
|
||||||
|
: T("Vergleich gegen Check-/Sollwert.", "Compared against check/reference value.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Color StatusColor(string status)
|
||||||
|
=> status == "OK" ? Color.Success
|
||||||
|
: status == "Pruefen" ? Color.Warning
|
||||||
|
: Color.Default;
|
||||||
|
|
||||||
|
private string StatusText(string status)
|
||||||
|
=> status == "OK" ? "OK"
|
||||||
|
: status == "Pruefen" ? T("Pruefen", "Check")
|
||||||
|
: T("Keine Daten", "No data");
|
||||||
|
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
@page "/finance-rules"
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
|
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
|
||||||
|
@using System.Reflection
|
||||||
|
@using TrafagSalesExporter.Models
|
||||||
|
@using TrafagSalesExporter.Services
|
||||||
|
@inject IFinanceRulesPageService FinanceRulesPageActions
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IUiTextService UiText
|
||||||
|
|
||||||
|
<PageTitle>@T("Finance Regeln", "Finance rules")</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-4">@T("Finance Regeln", "Finance rules")</MudText>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mb-3">
|
||||||
|
@T("Diese Regeln wirken nur auf die Finance-Sicht im zentralen Excel und im Abgleich. Rohdaten und Spaltenmapping bleiben unveraendert.",
|
||||||
|
"These rules only affect the finance view in the central Excel and reconciliation. Raw data and column mappings remain unchanged.")
|
||||||
|
</MudAlert>
|
||||||
|
|
||||||
|
<MudStack Row Spacing="2" Class="mb-3">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="AddRule">
|
||||||
|
@T("Regel hinzufuegen", "Add rule")
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Save" OnClick="SaveAllAsync">
|
||||||
|
@T("Alle speichern", "Save all")
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Text" Color="Color.Default" StartIcon="@Icons.Material.Filled.Restore" OnClick="LoadDefaults">
|
||||||
|
@T("Default-Regeln laden", "Load default rules")
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
|
<MudTable Items="_rules" Dense Hover Striped Breakpoint="Breakpoint.Md">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>Aktiv</MudTh>
|
||||||
|
<MudTh>Land</MudTh>
|
||||||
|
<MudTh>Jahr</MudTh>
|
||||||
|
<MudTh>Regeltyp</MudTh>
|
||||||
|
<MudTh>Feld</MudTh>
|
||||||
|
<MudTh>Vergleich</MudTh>
|
||||||
|
<MudTh>Wert</MudTh>
|
||||||
|
<MudTh>Sort</MudTh>
|
||||||
|
<MudTh>Notiz</MudTh>
|
||||||
|
<MudTh></MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd><MudCheckBox @bind-Value="context.IsActive" /></MudTd>
|
||||||
|
<MudTd><MudTextField @bind-Value="context.ScopeKey" Placeholder="DE" Style="width:80px" /></MudTd>
|
||||||
|
<MudTd><MudNumericField T="int?" @bind-Value="context.Year" Style="width:90px" /></MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudSelect T="string" @bind-Value="context.RuleType" Dense>
|
||||||
|
@foreach (var type in FinanceRuleTypes.All)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@type">@GetRuleTypeLabel(type)</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudSelect T="string" @bind-Value="context.FieldName" Dense Disabled="@UsesNoField(context)">
|
||||||
|
<MudSelectItem Value="@string.Empty">-</MudSelectItem>
|
||||||
|
@foreach (var field in _recordFields)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@field">@field</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudSelect T="string" @bind-Value="context.MatchType" Dense>
|
||||||
|
@foreach (var type in FinanceRuleMatchTypes.All)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@type">@GetMatchTypeLabel(type)</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd><MudTextField @bind-Value="context.MatchValue" Disabled="@UsesNoMatchValue(context)" /></MudTd>
|
||||||
|
<MudTd><MudNumericField T="int" @bind-Value="context.SortOrder" Style="width:80px" /></MudTd>
|
||||||
|
<MudTd><MudTextField @bind-Value="context.Notes" /></MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small"
|
||||||
|
OnClick="() => RemoveRule(context)" />
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private readonly string[] _recordFields = typeof(SalesRecord)
|
||||||
|
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||||
|
.Select(property => property.Name)
|
||||||
|
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
private List<FinanceRule> _rules = [];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_rules = await FinanceRulesPageActions.LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddRule()
|
||||||
|
{
|
||||||
|
_rules.Add(new FinanceRule
|
||||||
|
{
|
||||||
|
ScopeKey = "DE",
|
||||||
|
RuleType = FinanceRuleTypes.Exclude,
|
||||||
|
FieldName = nameof(SalesRecord.CustomerName),
|
||||||
|
MatchType = FinanceRuleMatchTypes.Contains,
|
||||||
|
SortOrder = _rules.Count == 0 ? 100 : _rules.Max(rule => rule.SortOrder) + 10,
|
||||||
|
IsActive = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveRule(FinanceRule rule) => _rules.Remove(rule);
|
||||||
|
|
||||||
|
private void LoadDefaults()
|
||||||
|
{
|
||||||
|
_rules = FinanceRuleEngine.CreateDefaultRules()
|
||||||
|
.Select(rule => new FinanceRule
|
||||||
|
{
|
||||||
|
ScopeKey = rule.ScopeKey,
|
||||||
|
Year = rule.Year,
|
||||||
|
RuleType = rule.RuleType,
|
||||||
|
FieldName = rule.FieldName,
|
||||||
|
MatchType = rule.MatchType,
|
||||||
|
MatchValue = rule.MatchValue,
|
||||||
|
Notes = rule.Notes,
|
||||||
|
SortOrder = rule.SortOrder,
|
||||||
|
IsActive = rule.IsActive
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAllAsync()
|
||||||
|
{
|
||||||
|
_rules = await FinanceRulesPageActions.SaveAllAsync(_rules);
|
||||||
|
Snackbar.Add(T("Finance-Regeln gespeichert.", "Finance rules saved."), Severity.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool UsesNoField(FinanceRule rule)
|
||||||
|
=> rule.RuleType == FinanceRuleTypes.ForceYear ||
|
||||||
|
rule.MatchType == FinanceRuleMatchTypes.Always;
|
||||||
|
|
||||||
|
private static bool UsesNoMatchValue(FinanceRule rule)
|
||||||
|
=> rule.MatchType is FinanceRuleMatchTypes.Always or FinanceRuleMatchTypes.IsBlank;
|
||||||
|
|
||||||
|
private string GetRuleTypeLabel(string type)
|
||||||
|
=> type switch
|
||||||
|
{
|
||||||
|
FinanceRuleTypes.Exclude => T("Ausschliessen", "Exclude"),
|
||||||
|
FinanceRuleTypes.NegateAmount => T("Betrag negativ", "Negate amount"),
|
||||||
|
FinanceRuleTypes.ForceYear => T("Jahr erzwingen", "Force year"),
|
||||||
|
FinanceRuleTypes.DeduplicateBlankSupplierCountry => T("Duplikate ohne Supplier Country", "Deduplicate blank supplier country"),
|
||||||
|
_ => type
|
||||||
|
};
|
||||||
|
|
||||||
|
private string GetMatchTypeLabel(string type)
|
||||||
|
=> type switch
|
||||||
|
{
|
||||||
|
FinanceRuleMatchTypes.Always => T("Immer", "Always"),
|
||||||
|
FinanceRuleMatchTypes.Equal => T("gleich", "equals"),
|
||||||
|
FinanceRuleMatchTypes.Contains => T("enthaelt", "contains"),
|
||||||
|
FinanceRuleMatchTypes.StartsWith => T("beginnt mit", "starts with"),
|
||||||
|
FinanceRuleMatchTypes.IsBlank => T("ist leer", "is blank"),
|
||||||
|
_ => type
|
||||||
|
};
|
||||||
|
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
@page "/finance-cockpit/schulung"
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
|
@inject TrafagSalesExporter.Services.IUiTextService UiText
|
||||||
|
|
||||||
|
<PageTitle>@T("Finance Schulung", "Finance training")</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-2">@T("Finance Schulung", "Finance training")</MudText>
|
||||||
|
<MudText Typo="Typo.body1" Class="mb-4 training-lead">
|
||||||
|
Ausführliche Anwenderunterlage für Finance-Keyuser, CFO/Finance-Leitung und Administratoren. Die Schulung beschreibt den
|
||||||
|
produktiven Ablauf vom manuellen Import bis zur zentralen Excel-Datei, inklusive Finance Summary, Finance Details und Soll/Ist Vergleich.
|
||||||
|
</MudText>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="7">
|
||||||
|
<img class="training-hero-image" src="training/finance_cockpit_preview.png" alt="Finance Cockpit Vorschau" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="5">
|
||||||
|
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-3">
|
||||||
|
Verbindlich ist die Finance-Sicht: Finance Summary, Finance Details und Soll/Ist Vergleich müssen zusammen plausibel sein.
|
||||||
|
</MudAlert>
|
||||||
|
<MudSimpleTable Dense Hover>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Rolle</th><th>Aufgabe</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Finance Keyuser</td><td>Importe bereitstellen, Standorte exportieren, Summen prüfen</td></tr>
|
||||||
|
<tr><td>CFO/Finance Leitung</td><td>Soll/Ist-Abgleich freigeben und Abweichungen entscheiden</td></tr>
|
||||||
|
<tr><td>Admin</td><td>Standorte, Mapping, Regeln, Settings und SharePoint konfigurieren</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudTabs Rounded Border>
|
||||||
|
<MudTabPanel Text="Prozess">
|
||||||
|
<TrainingSection Title="1. Ziel der Finance-Sicht">
|
||||||
|
<p>Das Finance Cockpit bereitet Sales- und Finance-Daten so auf, dass Länder, Systeme und unterschiedliche Spaltenlogiken in
|
||||||
|
einer zentralen Sicht vergleichbar werden. Die Rohdaten bleiben nachvollziehbar, die Finance-Spalten liefern die verbindliche
|
||||||
|
Abgrenzung für Summen und Soll/Ist-Vergleich.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Finance Summary</strong> zeigt die aggregierten Summen nach Jahr, Land und Währung.</li>
|
||||||
|
<li><strong>Finance Details</strong> zeigt die Detailzeilen, die zu diesen Summen führen.</li>
|
||||||
|
<li><strong>Sales</strong> enthält die breitere Rohdatensicht inklusive Finance-Spaltenblock.</li>
|
||||||
|
<li><strong>Soll/Ist Vergleich</strong> prüft die App-Daten gegen die gepflegten Referenzwerte.</li>
|
||||||
|
</ul>
|
||||||
|
</TrainingSection>
|
||||||
|
|
||||||
|
<TrainingSection Title="2. Bedienreihenfolge im Tagesgeschäft">
|
||||||
|
<div class="training-flow">
|
||||||
|
<div><span>1</span><strong>Dateien bereitstellen</strong><small>SharePoint, lokaler Pfad oder Quellsystem</small></div>
|
||||||
|
<div><span>2</span><strong>Standort exportieren</strong><small>CentralSalesRecords je Standort ersetzen</small></div>
|
||||||
|
<div><span>3</span><strong>Zentrale Excel erzeugen</strong><small>Sales, Summary und Details neu schreiben</small></div>
|
||||||
|
<div><span>4</span><strong>Summen prüfen</strong><small>Summary gegen Details aggregieren</small></div>
|
||||||
|
<div><span>5</span><strong>Soll/Ist freigeben</strong><small>Abweichungen dokumentieren</small></div>
|
||||||
|
</div>
|
||||||
|
<p>Nach jeder neuen Datei muss zuerst der betroffene Standort exportiert werden. Erst danach ist die zentrale Excel-Datei aktuell.</p>
|
||||||
|
</TrainingSection>
|
||||||
|
|
||||||
|
<TrainingSection Title="3. Prozessgrafik und Systembild">
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<img class="training-doc-image" src="training/keyuser-prozess.svg" alt="Keyuser Prozess" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<img class="training-doc-image" src="training/systemarchitektur.svg" alt="Systemarchitektur" />
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</TrainingSection>
|
||||||
|
</MudTabPanel>
|
||||||
|
|
||||||
|
<MudTabPanel Text="Importe">
|
||||||
|
<TrainingSection Title="4. Manuelle Importe und Delta-Regeln">
|
||||||
|
<p>Manuelle Importe ersetzen beim Standortexport den aktuellen Datenstand dieses Standorts in <code>CentralSalesRecords</code>.
|
||||||
|
Deshalb darf eine Delta-Datei nur dann verwendet werden, wenn die App sie zusammen mit einer Basisdatei liest.</p>
|
||||||
|
<MudSimpleTable Dense Hover>
|
||||||
|
<thead><tr><th>Land</th><th>Quelle</th><th>Lieferlogik</th><th>Konsequenz</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>UK / England</td><td>Sage Excel/CSV im Ordner UK_B1</td><td>Jahresdatei plus Deltas</td><td>Delta-fähig, weil Basis und Deltas zusammen gelesen werden</td></tr>
|
||||||
|
<tr><td>Spanien</td><td>Sage CSV / Manual Excel</td><td>Vollfile erforderlich</td><td>Keine Delta-Dateien verwenden</td></tr>
|
||||||
|
<tr><td>Deutschland</td><td>Alphaplan Excel</td><td>Vollfile/Jahresfile erforderlich</td><td>Keine Delta-Dateien verwenden</td></tr>
|
||||||
|
<tr><td>CH/AT</td><td>SAP OData</td><td>Quellsystem wird neu gelesen</td><td>Kein manueller Delta-Prozess</td></tr>
|
||||||
|
<tr><td>FR/IT/US/IN</td><td>HANA/SAP B1/Sage</td><td>direkte Quelle</td><td>Standortdaten werden aus Quelle aufgebaut</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Class="mt-3">
|
||||||
|
Spanien und Deutschland müssen vollständige Dateien liefern. Eine einzelne Delta-Datei würde technisch den bisherigen Stand ersetzen und damit unvollständig werden.
|
||||||
|
</MudAlert>
|
||||||
|
</TrainingSection>
|
||||||
|
|
||||||
|
<TrainingSection Title="5. Standortexport verstehen">
|
||||||
|
<p>Der Standortexport ist der Schritt, der Rohdaten aus der jeweiligen Quelle in die zentrale Datenbank schreibt. Er liest nicht nur
|
||||||
|
eine Datei, sondern wendet auch Mapping, Transformationen und Finance-Regeln an.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Bei SAP/HANA wird die definierte Quelle abgefragt.</li>
|
||||||
|
<li>Bei manuellen Excel-/CSV-Importen wird die hinterlegte Datei oder der hinterlegte Ordner verwendet.</li>
|
||||||
|
<li>Bestehende Zeilen des Standorts werden ersetzt, damit keine veralteten Dubletten stehen bleiben.</li>
|
||||||
|
<li>Fehler stehen in den Logs und müssen vor der zentralen Excel-Erzeugung geklärt werden.</li>
|
||||||
|
</ul>
|
||||||
|
</TrainingSection>
|
||||||
|
|
||||||
|
<TrainingSection Title="6. Zentrale Excel-Datei">
|
||||||
|
<MudSimpleTable Dense Hover>
|
||||||
|
<thead><tr><th>Blatt</th><th>Zweck</th><th>Prüfung</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Sales</td><td>Rohdaten plus normalisierte Spalten</td><td>Land, TSC, Beleg, Kunde, Wert, Währung</td></tr>
|
||||||
|
<tr><td>Finance Summary</td><td>verbindliche Aggregation</td><td>Jahr, Land, Währung, Net Sales Actual</td></tr>
|
||||||
|
<tr><td>Finance Details</td><td>Detailzeilen zur Summary</td><td>Summe je Land muss Summary ergeben</td></tr>
|
||||||
|
<tr><td>Finance Filter Hilfe</td><td>Hinweise zur Excel-Prüfung</td><td>Filter und Pivot-Anleitung</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
</TrainingSection>
|
||||||
|
</MudTabPanel>
|
||||||
|
|
||||||
|
<MudTabPanel Text="Abgleich">
|
||||||
|
<TrainingSection Title="7. Finance Summary lesen">
|
||||||
|
<p>Finance Summary ist die kompakte Sicht für Finance. Sie enthält nur Zeilen, die nach Finance-Regel eingeschlossen sind.
|
||||||
|
Entscheidend sind Finance-Jahr, Country Key, Währung und Net Sales Actual.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Immer zuerst nach Jahr 2025 filtern, wenn der 2025-Abgleich geprüft wird.</li>
|
||||||
|
<li>Country Key ist die Finance-Länderlogik, nicht zwingend nur der sichtbare Rohdaten-Ländername.</li>
|
||||||
|
<li>Währung muss zum Referenzwert passen, zum Beispiel GBP für UK oder INR für Indien.</li>
|
||||||
|
<li>Included Rows zeigt, wie viele Detailzeilen in die Summe geflossen sind.</li>
|
||||||
|
</ul>
|
||||||
|
</TrainingSection>
|
||||||
|
|
||||||
|
<TrainingSection Title="8. Finance Details gegen Summary prüfen">
|
||||||
|
<p>Finance Details ist das Kontrollblatt für Rückfragen. Die Summe über <code>Net Sales Actual</code> in Finance Details muss
|
||||||
|
je Jahr, Land und Währung exakt mit Finance Summary übereinstimmen.</p>
|
||||||
|
<MudSimpleTable Dense Hover>
|
||||||
|
<thead><tr><th>Prüfung</th><th>Vorgehen</th><th>Erwartung</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Landessumme</td><td>Details nach Year, Country Key und Currency aggregieren</td><td>identisch zu Finance Summary</td></tr>
|
||||||
|
<tr><td>Einzelbeleg</td><td>Invoice Number, Position und Document Entry suchen</td><td>Beleg nachvollziehbar</td></tr>
|
||||||
|
<tr><td>Ausschluss</td><td>Sales-Blatt mit Finance Include vergleichen</td><td>Regelgrund sichtbar</td></tr>
|
||||||
|
<tr><td>Dubletten</td><td>Belegkopfwerte und Positionen prüfen</td><td>B1-Kopflogik wird nicht doppelt gezählt</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
</TrainingSection>
|
||||||
|
|
||||||
|
<TrainingSection Title="9. Soll/Ist Vergleich">
|
||||||
|
<p>Der Soll/Ist Vergleich nutzt dieselbe Finance-Reconciliation-Logik wie die zentrale Finance-Sicht. Er ist die Seite für
|
||||||
|
Freigabe, Abweichungsanalyse und Status je Land.</p>
|
||||||
|
<MudSimpleTable Dense Hover>
|
||||||
|
<thead><tr><th>Status</th><th>Bedeutung</th><th>Aktion</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>OK</td><td>Istwert passt gegen Referenz innerhalb Toleranz</td><td>für Freigabe vormerken</td></tr>
|
||||||
|
<tr><td>Prüfen</td><td>Differenz vorhanden oder Regel noch nicht final</td><td>Details und Länderregel prüfen</td></tr>
|
||||||
|
<tr><td>Keine Daten</td><td>Kein aktueller Stand in CentralSalesRecords</td><td>Standortexport oder Import prüfen</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
</TrainingSection>
|
||||||
|
|
||||||
|
<TrainingSection Title="10. Länderlogik kompakt">
|
||||||
|
<MudSimpleTable Dense Hover>
|
||||||
|
<thead><tr><th>Land</th><th>Aktuelle Hauptlogik</th><th>Hinweis</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>DE</td><td>Alphaplan NettoPreisGesamtX, GS negativ, Finance-Ausschlüsse</td><td>Vollfile erforderlich</td></tr>
|
||||||
|
<tr><td>ES</td><td>Sage ImporteNeto, REC/Credit negativ</td><td>Vollfile erforderlich</td></tr>
|
||||||
|
<tr><td>IT</td><td>B1 Positions-Netto, Trafag Italia ausgeschlossen, Dublettenlogik</td><td>Detailprüfung wichtig</td></tr>
|
||||||
|
<tr><td>UK</td><td>Sage Netto in GBP, Credit Notes negativ</td><td>Basis plus Deltas möglich</td></tr>
|
||||||
|
<tr><td>FR/US</td><td>B1 Sales Price/Value bevorzugt</td><td>gegen CheckValue prüfen</td></tr>
|
||||||
|
<tr><td>IN</td><td>Hauswährung INR</td><td>Referenz in Local Currency</td></tr>
|
||||||
|
<tr><td>CH/AT</td><td>SAP OData NetwrHc</td><td>AT hat Referenz, CH aktuell ohne Sollwert</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
</TrainingSection>
|
||||||
|
</MudTabPanel>
|
||||||
|
|
||||||
|
<MudTabPanel Text="Spartenanalyse">
|
||||||
|
<TrainingSection Title="11. Spartenanalyse oeffnen">
|
||||||
|
<p>Die Spartenanalyse liegt unter <strong>Management Analyse</strong>. Im linken Menue kann Management Analyse aufgeklappt werden.
|
||||||
|
Dort fuehren die Punkte <strong>Sparten-Finanzanalyse</strong> und <strong>Zentrale Spartenzuordnung</strong> direkt in die passende Sicht.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Sparten-Finanzanalyse</strong> zeigt Umsatz, Anteil, Materialien, Zeilen und Laender nach Produktspartenlogik.</li>
|
||||||
|
<li><strong>Zentrale Spartenzuordnung</strong> prueft lokale Materialnummern gegen die fuehrende TR-AG-Referenz.</li>
|
||||||
|
<li>Die lokalen ERP-Produktsparten werden fuer diese Fuehrungssicht nicht verwendet; massgebend ist die TR-AG-Referenz aus SAP.</li>
|
||||||
|
</ul>
|
||||||
|
</TrainingSection>
|
||||||
|
|
||||||
|
<TrainingSection Title="12. Gruppierung, Top 10 und Laender">
|
||||||
|
<MudSimpleTable Dense Hover>
|
||||||
|
<thead><tr><th>Funktion</th><th>Zweck</th><th>Pruefung</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Gruppierung PAPH1 Detail</td><td>Feinste Sicht auf einzelne Produkt-Hierarchie-Codes</td><td>Geeignet fuer Detailanalyse und auffaellige Einzelgruppen</td></tr>
|
||||||
|
<tr><td>Gruppierung Produktfamilie</td><td>Fasst mehrere PAPH1-Zeilen zusammen, z.B. Gas Density Monitor</td><td>Geeignet fuer Managementsicht und Portfolioanalyse</td></tr>
|
||||||
|
<tr><td>Gruppierung Produktsparte</td><td>Verdichtet auf die oberste Spartenebene</td><td>Geeignet fuer schnelle Umsatzverteilung nach Sparten</td></tr>
|
||||||
|
<tr><td>Top 10 anzeigen</td><td>Reduziert die Tabelle auf die groessten Umsatzbloecke</td><td>Hilft, die wichtigsten Sparten ohne lange Detailliste zu beurteilen</td></tr>
|
||||||
|
<tr><td>Flaggen bei Laendern</td><td>Zeigt Landkuerzel optisch schneller erkennbar an</td><td>Bei mehreren Laendern werden die Laender kommasepariert angezeigt</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mt-3">
|
||||||
|
Die Umsaetze und Anteile werden je Gruppierung neu aggregiert. Top 10 filtert nur die Anzeige, nicht die Berechnungsbasis.
|
||||||
|
</MudAlert>
|
||||||
|
</TrainingSection>
|
||||||
|
|
||||||
|
<TrainingSection Title="13. Sparten-Icons lesen">
|
||||||
|
<p>Die Icons neben der Produktsparte sind eine visuelle Orientierung. Sie werden aus Sparten-, Familien- und PAPH1-Texten abgeleitet
|
||||||
|
und aendern keine Zahlen oder Zuordnung.</p>
|
||||||
|
<MudSimpleTable Dense Hover>
|
||||||
|
<thead><tr><th>Textmuster</th><th>Icon</th><th>Bedeutung</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Gas / Density</td><td>Sensors</td><td>Gas-Density-Produkte</td></tr>
|
||||||
|
<tr><td>Pressure / Druck</td><td>Compress</td><td>Druckprodukte</td></tr>
|
||||||
|
<tr><td>Temp / Thermostat</td><td>DeviceThermostat</td><td>Temperatur und Thermostate</td></tr>
|
||||||
|
<tr><td>Switch / Schalter</td><td>ToggleOn</td><td>Schalterbasierte Produkte</td></tr>
|
||||||
|
<tr><td>Access / Zubehoer</td><td>Extension</td><td>Zubehoer</td></tr>
|
||||||
|
<tr><td>UNASS / Nicht zugeordnet</td><td>HelpOutline</td><td>TR-AG-Referenz vorhanden, aber ohne gueltige Sparte</td></tr>
|
||||||
|
<tr><td>Sonstige Texte</td><td>Category</td><td>Keine spezifische Icon-Regel getroffen</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
</TrainingSection>
|
||||||
|
|
||||||
|
<TrainingSection Title="14. Interpretation fuer Finance">
|
||||||
|
<ul>
|
||||||
|
<li><strong>Zugeordnet</strong>: Material wurde im TR-AG-Stamm gefunden und hat eine verwertbare Sparteninformation.</li>
|
||||||
|
<li><strong>Nicht zugeordnet</strong>: Material wurde gefunden, aber die TR-AG-Referenz ist unassigned oder leer.</li>
|
||||||
|
<li><strong>Nicht im TR-AG-Stamm</strong>: Lokale Materialnummer konnte nicht gegen die TR-AG-Referenz gematcht werden.</li>
|
||||||
|
<li><strong>Material fehlt</strong>: In der Finance-Zeile fehlt die Materialnummer; diese Zeilen koennen nicht zugeordnet werden.</li>
|
||||||
|
</ul>
|
||||||
|
<p>Fuer Finanzfragen ist die wichtigste Sicht zuerst die aggregierte Produktfamilie oder Produktsparte. PAPH1 Detail wird genutzt,
|
||||||
|
wenn eine Abweichung oder ein auffaelliger Umsatzblock genauer erklaert werden muss.</p>
|
||||||
|
</TrainingSection>
|
||||||
|
</MudTabPanel>
|
||||||
|
|
||||||
|
<MudTabPanel Text="Fehler & Freigabe">
|
||||||
|
<TrainingSection Title="15. Typische Fehler und Massnahmen">
|
||||||
|
<MudSimpleTable Dense Hover>
|
||||||
|
<thead><tr><th>Fehler</th><th>Ursache</th><th>Massnahme</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Zentrale Datei konnte nicht erzeugt werden</td><td>Excel/SharePoint-Datei gesperrt oder Uploadfehler</td><td>Datei schliessen, Logs prüfen, ggf. Zeitstempeldatei verwenden</td></tr>
|
||||||
|
<tr><td>Land fehlt in Summary</td><td>Standort nicht exportiert oder keine Finance Include Zeilen</td><td>Standortexport und Finance-Regeln prüfen</td></tr>
|
||||||
|
<tr><td>Summe passt nicht zum Soll</td><td>falsche Datei, falsches Jahr, Länderregel offen</td><td>Finance Details aggregieren und Länderlogik prüfen</td></tr>
|
||||||
|
<tr><td>Zu wenige Zeilen nach Import</td><td>Delta statt Vollfile verwendet</td><td>bei ES/DE vollständige Datei neu liefern lassen</td></tr>
|
||||||
|
<tr><td>Keine Verbindung zur Quelle</td><td>Credentials, Netzwerk, HANA/SAP nicht erreichbar</td><td>Settings und Logs prüfen</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
</TrainingSection>
|
||||||
|
|
||||||
|
<TrainingSection Title="16. Freigabe-Checkliste">
|
||||||
|
<MudSimpleTable Dense Hover>
|
||||||
|
<thead><tr><th>Nr.</th><th>Prüfpunkt</th><th>OK</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>1</td><td>Alle relevanten Standorte wurden nach letzter Dateiänderung exportiert</td><td></td></tr>
|
||||||
|
<tr><td>2</td><td>Zentrale Excel wurde danach neu erzeugt</td><td></td></tr>
|
||||||
|
<tr><td>3</td><td>Finance Summary stimmt aggregiert mit Finance Details überein</td><td></td></tr>
|
||||||
|
<tr><td>4</td><td>Soll/Ist Vergleich enthält keine unerwarteten Abweichungen</td><td></td></tr>
|
||||||
|
<tr><td>5</td><td>ES und DE wurden als Vollfile geliefert</td><td></td></tr>
|
||||||
|
<tr><td>6</td><td>Offene Länder- oder Regelentscheidungen sind dokumentiert</td><td></td></tr>
|
||||||
|
<tr><td>7</td><td>SharePoint/Excel-Datei ist nicht mehr gesperrt</td><td></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
</TrainingSection>
|
||||||
|
</MudTabPanel>
|
||||||
|
</MudTabs>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.training-lead { max-width: 980px; color: var(--mud-palette-text-secondary); }
|
||||||
|
.training-hero-image, .training-doc-image { width: 100%; border: 1px solid var(--mud-palette-lines-default); border-radius: 6px; background: #fff; }
|
||||||
|
.training-doc-image { margin-bottom: 12px; }
|
||||||
|
.training-section { margin: 18px 0 28px; max-width: 1180px; }
|
||||||
|
.training-section p { margin-bottom: 12px; line-height: 1.55; }
|
||||||
|
.training-section ul { margin-top: 8px; }
|
||||||
|
.training-flow { display: grid; grid-template-columns: repeat(5, minmax(120px, 1fr)); gap: 10px; margin: 12px 0 18px; }
|
||||||
|
.training-flow div { border: 1px solid var(--mud-palette-lines-default); border-radius: 6px; padding: 12px; background: var(--mud-palette-surface); }
|
||||||
|
.training-flow span { display: inline-flex; width: 28px; height: 28px; align-items: center; justify-content: center; border-radius: 50%; background: var(--mud-palette-primary); color: var(--mud-palette-primary-text); font-weight: 700; margin-bottom: 8px; }
|
||||||
|
.training-flow strong, .training-flow small { display: block; }
|
||||||
|
.training-flow small { color: var(--mud-palette-text-secondary); margin-top: 4px; }
|
||||||
|
@@media (max-width: 900px) { .training-flow { grid-template-columns: 1fr; } }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
}
|
||||||
@@ -0,0 +1,669 @@
|
|||||||
|
@page "/hr-kpi"
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
|
@using System.Globalization
|
||||||
|
@using System.Text.Json
|
||||||
|
@using TrafagSalesExporter.Components.HrKpi
|
||||||
|
@using TrafagSalesExporter.Services
|
||||||
|
@inject IHrKpiService HrKpiService
|
||||||
|
@inject IHrKpiAccessService HrKpiAccess
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IUiTextService UiText
|
||||||
|
@inject IJSRuntime JsRuntime
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject IWebHostEnvironment Environment
|
||||||
|
|
||||||
|
<PageTitle>@T("HR KPI", "HR KPI")</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-4">@T("HR KPI", "HR KPI")</MudText>
|
||||||
|
|
||||||
|
@if (!CanShowHrKpi)
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="1" Style="max-width:520px;">
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined">
|
||||||
|
@T("HR KPI enthaelt sensible Personaldaten. Bitte separat anmelden.", "HR KPI contains sensitive HR data. Please sign in separately.")
|
||||||
|
</MudAlert>
|
||||||
|
@if (!HrKpiAccess.IsConfigured)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Error" Variant="Variant.Filled">
|
||||||
|
@T("HR-KPI-Zugang ist noch nicht konfiguriert. Bitte Username und PasswordHash in HrKpiAccess konfigurieren.", "HR KPI access is not configured yet. Please configure Username and PasswordHash in HrKpiAccess.")
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
<form method="post" action="@AccessUrl">
|
||||||
|
<input type="hidden" name="returnUrl" value="@Navigation.Uri" />
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
<MudTextField T="string" Name="username" Label="@T("Name", "Name")" Disabled="@(!HrKpiAccess.IsConfigured)" />
|
||||||
|
<MudTextField T="string" Name="password" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!HrKpiAccess.IsConfigured)" />
|
||||||
|
<button type="submit" class="mud-button-root mud-button mud-button-filled mud-button-filled-primary mud-button-filled-size-medium mud-ripple">
|
||||||
|
@T("HR KPI entsperren", "Unlock HR KPI")
|
||||||
|
</button>
|
||||||
|
</MudStack>
|
||||||
|
</form>
|
||||||
|
<MudText Typo="Typo.caption">
|
||||||
|
@T("Server-Klicks", "Server clicks"): @_unlockClickCount |
|
||||||
|
@T("Konfiguriert", "Configured"): @(HrKpiAccess.IsConfigured ? "JA" : "NEIN")
|
||||||
|
</MudText>
|
||||||
|
<MudDivider />
|
||||||
|
<MudExpansionPanels Elevation="0">
|
||||||
|
<MudExpansionPanel Text="@T("Passwort ändern", "Change password")" Icon="@Icons.Material.Filled.Password">
|
||||||
|
<MudStack Spacing="3" Class="pt-2">
|
||||||
|
<MudTextField @bind-Value="_changeUsername" Label="@T("Name", "Name")" Disabled="@(!HrKpiAccess.IsConfigured)" />
|
||||||
|
<MudTextField @bind-Value="_currentPassword" Label="@T("Aktuelles Passwort", "Current password")" InputType="InputType.Password" Disabled="@(!HrKpiAccess.IsConfigured)" />
|
||||||
|
<MudTextField @bind-Value="_newPassword" Label="@T("Neues Passwort", "New password")" InputType="InputType.Password" HelperText="@T("Mindestens 8 Zeichen.", "At least 8 characters.")" Disabled="@(!HrKpiAccess.IsConfigured)" />
|
||||||
|
<MudTextField @bind-Value="_newPasswordRepeat" Label="@T("Neues Passwort wiederholen", "Repeat new password")" InputType="InputType.Password" Disabled="@(!HrKpiAccess.IsConfigured)" />
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="ChangeHrPasswordAsync"
|
||||||
|
StartIcon="@Icons.Material.Filled.Save" Disabled="@(!HrKpiAccess.IsConfigured)">
|
||||||
|
@T("Passwort speichern", "Save password")
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudExpansionPanel>
|
||||||
|
</MudExpansionPanels>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="5">
|
||||||
|
<MudTextField @bind-Value="_dataFolder"
|
||||||
|
Label="@T("Datenordner fuer Rexx/SAP-Dateien", "Data folder for Rexx/SAP files")"
|
||||||
|
HelperText="@T("Serverordner fuer hochgeladene HR-KPI-Dateien. Auf der publizierten Webseite ist das ein Ordner auf dem Webserver, nicht C:\\temp auf dem lokalen PC.", "Server folder for uploaded HR KPI files. On the published site this is a folder on the web server, not C:\\temp on the local PC.")" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="5">
|
||||||
|
<MudStack Spacing="1">
|
||||||
|
<MudText Typo="Typo.caption">
|
||||||
|
@T("Erwartete Dateien", "Expected files"): @string.Join(", ", ExpectedUploadFileNames)
|
||||||
|
</MudText>
|
||||||
|
<InputFile OnChange="UploadHrKpiFilesAsync" multiple accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" disabled="@(_loading || _uploading)" />
|
||||||
|
<MudText Typo="Typo.caption">
|
||||||
|
@T("Uploadziel", "Upload target"): @_serverUploadFolder
|
||||||
|
</MudText>
|
||||||
|
</MudStack>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="UseServerUploadFolderAsync"
|
||||||
|
StartIcon="@Icons.Material.Filled.Folder" Disabled="@(_loading || _uploading)" FullWidth>
|
||||||
|
@T("Serverordner nutzen", "Use server folder")
|
||||||
|
</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6" md="2">
|
||||||
|
<MudSelect T="int?" @bind-Value="_year" Label="@T("Austrittsjahr", "Leaver year")" Dense Clearable>
|
||||||
|
@foreach (var option in _result?.ExitYearOptions ?? [])
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@((int?)option)">@option</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="3">
|
||||||
|
<MudSelect T="string" @bind-Value="_organisation" Label="@T("Organisation", "Organisation")" Dense Clearable>
|
||||||
|
@foreach (var option in _result?.OrganisationOptions ?? [])
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@option">@option</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="LoadAsync"
|
||||||
|
StartIcon="@Icons.Material.Filled.Refresh" Disabled="_loading" FullWidth>
|
||||||
|
@(_loading ? T("Lade...", "Loading...") : T("Laden", "Load"))
|
||||||
|
</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2">
|
||||||
|
<MudSwitch T="bool" @bind-Value="_managementView" Color="Color.Primary"
|
||||||
|
Label="@T("Managementsicht", "Management view")" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="3">
|
||||||
|
<MudDatePicker @bind-Date="_fromDate" Label="@T("Von Datum", "From date")" Clearable DateFormat="dd.MM.yyyy" Culture="_dateCulture" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="3">
|
||||||
|
<MudDatePicker @bind-Date="_toDate" Label="@T("Bis Datum", "To date")" Clearable DateFormat="dd.MM.yyyy" Culture="_dateCulture" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2">
|
||||||
|
<MudSelect T="int?" @bind-Value="_entryYear" Label="@T("Eintrittsjahr", "Entry year")" Dense Clearable>
|
||||||
|
@foreach (var option in _result?.EntryYearOptions ?? [])
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@((int?)option)">@option</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudTextField @bind-Value="_searchText" Label="@T("Suche Name / Personalnr.", "Search name / personnel no.")" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="3">
|
||||||
|
<MudSelect T="string" @bind-Value="_kostenstelle" Label="@T("Kostenstelle", "Cost center")" Dense Clearable>
|
||||||
|
@foreach (var option in _result?.KostenstelleOptions ?? [])
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@option">@option</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="3">
|
||||||
|
<MudSelect T="string" @bind-Value="_mitarbeitertyp" Label="@T("Mitarbeitertyp", "Employee type")" Dense Clearable>
|
||||||
|
@foreach (var option in _result?.MitarbeitertypOptions ?? [])
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@option">@option</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2">
|
||||||
|
<MudSelect T="string" @bind-Value="_fluktuationFilter" Label="@T("Fluktuation", "Turnover")" Dense>
|
||||||
|
@foreach (var option in _fluktuationOptions)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@option.Key">@option.Label</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6" md="2">
|
||||||
|
<MudSelect T="string" @bind-Value="_glzAmpel" Label="@T("GLZ", "Time")" Dense Clearable>
|
||||||
|
@foreach (var option in _ampelOptions)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@option">@option</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6" md="2">
|
||||||
|
<MudSelect T="string" @bind-Value="_restferienAmpel" Label="@T("Restferien", "Vacation")" Dense Clearable>
|
||||||
|
@foreach (var option in _restferienOptions)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@option">@option</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" OnClick="LockHrKpi"
|
||||||
|
StartIcon="@Icons.Material.Filled.Lock" FullWidth>
|
||||||
|
@T("Sperren", "Lock")
|
||||||
|
</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="PrintAsync"
|
||||||
|
StartIcon="@Icons.Material.Filled.Print" FullWidth>
|
||||||
|
@T("Drucken/PDF", "Print/PDF")
|
||||||
|
</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudDivider Class="my-2" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="3">
|
||||||
|
<MudSelect T="string" @bind-Value="_selectedVariantName" Label="@T("Variante", "Variant")" Dense Clearable>
|
||||||
|
@foreach (var variant in _variantNames)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@variant">@variant</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="3">
|
||||||
|
<MudTextField @bind-Value="_variantName" Label="@T("Variantenname", "Variant name")" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="SaveVariantAsync"
|
||||||
|
StartIcon="@Icons.Material.Filled.Save" Disabled="_loading" FullWidth>
|
||||||
|
@T("Variante speichern", "Save variant")
|
||||||
|
</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="RenameVariantAsync"
|
||||||
|
StartIcon="@Icons.Material.Filled.Edit" Disabled="@(_loading || string.IsNullOrWhiteSpace(_selectedVariantName) || string.IsNullOrWhiteSpace(_variantName))" FullWidth>
|
||||||
|
@T("Umbenennen", "Rename")
|
||||||
|
</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="LoadVariantAsync"
|
||||||
|
StartIcon="@Icons.Material.Filled.FileOpen" Disabled="@(_loading || string.IsNullOrWhiteSpace(_selectedVariantName))" FullWidth>
|
||||||
|
@T("Variante laden", "Load variant")
|
||||||
|
</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="DeleteVariantAsync"
|
||||||
|
StartIcon="@Icons.Material.Filled.Delete" Disabled="@(_loading || string.IsNullOrWhiteSpace(_selectedVariantName))" FullWidth>
|
||||||
|
@T("Löschen", "Delete")
|
||||||
|
</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" OnClick="UpdateSelectedVariantAsync"
|
||||||
|
StartIcon="@Icons.Material.Filled.Update" Disabled="@(_loading || string.IsNullOrWhiteSpace(_selectedVariantName))" FullWidth>
|
||||||
|
@T("Bestehende anpassen", "Update existing")
|
||||||
|
</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (CanShowHrKpi && _result is not null)
|
||||||
|
{
|
||||||
|
@if (_result.Notices.Count > 0)
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
||||||
|
@foreach (var notice in _result.Notices)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Warning" Dense Variant="Variant.Outlined" Class="mb-2">@notice</MudAlert>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
|
||||||
|
<HrKpiDashboardTabs Result="_result" />
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string _dataFolder = HrKpiDataSourceOptions.DefaultFolder;
|
||||||
|
private int? _year;
|
||||||
|
private DateTime? _fromDate;
|
||||||
|
private DateTime? _toDate;
|
||||||
|
private int? _entryYear;
|
||||||
|
private string? _organisation;
|
||||||
|
private string? _kostenstelle;
|
||||||
|
private string? _mitarbeitertyp;
|
||||||
|
private string _fluktuationFilter = "Alle";
|
||||||
|
private string? _glzAmpel;
|
||||||
|
private string? _restferienAmpel;
|
||||||
|
private string? _searchText;
|
||||||
|
private bool _managementView;
|
||||||
|
private string? _hrUsername;
|
||||||
|
private string? _hrPassword;
|
||||||
|
private string? _changeUsername;
|
||||||
|
private string? _currentPassword;
|
||||||
|
private string? _newPassword;
|
||||||
|
private string? _newPasswordRepeat;
|
||||||
|
private int _unlockClickCount;
|
||||||
|
private string AccessUrl => new Uri(new Uri(Navigation.BaseUri), "access/hr").ToString();
|
||||||
|
private bool _loading;
|
||||||
|
private bool _uploading;
|
||||||
|
private string _serverUploadFolder = string.Empty;
|
||||||
|
private HrKpiResult? _result;
|
||||||
|
private string _selectionStorePath = string.Empty;
|
||||||
|
private HrKpiSelectionStore _selectionStore = new();
|
||||||
|
private string? _variantName;
|
||||||
|
private string? _selectedVariantName;
|
||||||
|
private List<string> _variantNames = [];
|
||||||
|
private static readonly SemaphoreSlim SelectionStoreLock = new(1, 1);
|
||||||
|
private static readonly string[] ExpectedUploadFileNames =
|
||||||
|
[
|
||||||
|
"Saldiperstichdatum.xlsx",
|
||||||
|
"Exportkommengehen.xlsx",
|
||||||
|
"HR_KPI_Export.xlsx",
|
||||||
|
"Abwesenheitinstunden.xlsx",
|
||||||
|
"Personalausgeschieden.xlsx"
|
||||||
|
];
|
||||||
|
private readonly List<(string Key, string Label)> _fluktuationOptions =
|
||||||
|
[
|
||||||
|
("Alle", "Alle"),
|
||||||
|
("Fluktuationsrelevant", "Relevant"),
|
||||||
|
("Arbeitnehmerkuendigung", "Arbeitnehmerkuendigung"),
|
||||||
|
("Ausgeschlossen", "Ausgeschlossen")
|
||||||
|
];
|
||||||
|
private readonly List<string> _ampelOptions = ["Gruen", "Gelb", "Rot"];
|
||||||
|
private readonly List<string> _restferienOptions = ["Gruen", "Rot"];
|
||||||
|
private readonly CultureInfo _dateCulture = CultureInfo.GetCultureInfo("de-CH");
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_serverUploadFolder = Path.Combine(Environment.ContentRootPath, "hrdata");
|
||||||
|
Directory.CreateDirectory(_serverUploadFolder);
|
||||||
|
_selectionStorePath = Path.Combine(_serverUploadFolder, "hr-kpi-variants.json");
|
||||||
|
_selectionStore = await ReadSelectionStoreAsync();
|
||||||
|
_dataFolder = _serverUploadFolder;
|
||||||
|
if (_selectionStore.LastSelection is not null)
|
||||||
|
ApplySelectionState(_selectionStore.LastSelection);
|
||||||
|
else
|
||||||
|
_mitarbeitertyp = "Festangestellt";
|
||||||
|
RefreshVariantNames();
|
||||||
|
if (CanShowHrKpi)
|
||||||
|
{
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
if (!CanShowHrKpi)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_loading = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_result = await HrKpiService.BuildAsync(new HrKpiOptions
|
||||||
|
{
|
||||||
|
DataFolder = _dataFolder,
|
||||||
|
Year = _year,
|
||||||
|
FromDate = _fromDate,
|
||||||
|
ToDate = _toDate,
|
||||||
|
EntryYear = _entryYear,
|
||||||
|
Organisationseinheit = _organisation,
|
||||||
|
KostenstelleText = _kostenstelle,
|
||||||
|
Mitarbeitertyp = _mitarbeitertyp,
|
||||||
|
FluktuationFilter = _fluktuationFilter,
|
||||||
|
GlzAmpel = _glzAmpel,
|
||||||
|
RestferienAmpel = _restferienAmpel,
|
||||||
|
SearchText = _searchText,
|
||||||
|
ManagementView = _managementView
|
||||||
|
});
|
||||||
|
_selectionStore.LastSelection = CreateSelectionState();
|
||||||
|
await WriteSelectionStoreAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add(ex.Message, Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UploadHrKpiFilesAsync(InputFileChangeEventArgs args)
|
||||||
|
{
|
||||||
|
if (!CanShowHrKpi)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_uploading = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_serverUploadFolder);
|
||||||
|
var uploaded = 0;
|
||||||
|
var skipped = new List<string>();
|
||||||
|
var expected = ExpectedUploadFileNames.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var file in args.GetMultipleFiles(10))
|
||||||
|
{
|
||||||
|
var fileName = Path.GetFileName(file.Name);
|
||||||
|
if (!expected.Contains(fileName))
|
||||||
|
{
|
||||||
|
skipped.Add(fileName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetPath = Path.Combine(_serverUploadFolder, fileName);
|
||||||
|
await using var source = file.OpenReadStream(50 * 1024 * 1024);
|
||||||
|
await using var target = File.Create(targetPath);
|
||||||
|
await source.CopyToAsync(target);
|
||||||
|
uploaded++;
|
||||||
|
}
|
||||||
|
|
||||||
|
_dataFolder = _serverUploadFolder;
|
||||||
|
|
||||||
|
if (uploaded > 0)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"{uploaded} HR-KPI-Datei(en) auf den Server geladen.", Severity.Success);
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipped.Count > 0)
|
||||||
|
Snackbar.Add($"Nicht uebernommen, weil Dateiname nicht erwartet wird: {string.Join(", ", skipped)}", Severity.Warning);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Upload fehlgeschlagen: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_uploading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UseServerUploadFolderAsync()
|
||||||
|
{
|
||||||
|
_dataFolder = _serverUploadFolder;
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveVariantAsync()
|
||||||
|
{
|
||||||
|
var name = (_variantName ?? _selectedVariantName)?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Bitte Variantenname eingeben.", "Please enter a variant name."), Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectionStore = await ReadSelectionStoreAsync();
|
||||||
|
_selectionStore.Variants[name] = CreateSelectionState();
|
||||||
|
await WriteSelectionStoreAsync();
|
||||||
|
RefreshVariantNames();
|
||||||
|
_selectedVariantName = name;
|
||||||
|
_variantName = name;
|
||||||
|
Snackbar.Add($"{T("Variante gespeichert", "Variant saved")}: {name}", Severity.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpdateSelectedVariantAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_selectedVariantName))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var name = _selectedVariantName.Trim();
|
||||||
|
_selectionStore = await ReadSelectionStoreAsync();
|
||||||
|
_selectionStore.Variants[name] = CreateSelectionState();
|
||||||
|
await WriteSelectionStoreAsync();
|
||||||
|
RefreshVariantNames();
|
||||||
|
_selectedVariantName = name;
|
||||||
|
_variantName = name;
|
||||||
|
Snackbar.Add($"{T("Variante aktualisiert", "Variant updated")}: {name}", Severity.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RenameVariantAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_selectedVariantName) || string.IsNullOrWhiteSpace(_variantName))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var oldName = _selectedVariantName.Trim();
|
||||||
|
var newName = _variantName.Trim();
|
||||||
|
if (string.Equals(oldName, newName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Der Variantenname ist unverändert.", "The variant name is unchanged."), Severity.Info);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectionStore = await ReadSelectionStoreAsync();
|
||||||
|
if (!_selectionStore.Variants.TryGetValue(oldName, out var selection))
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Variante nicht gefunden.", "Variant not found."), Severity.Warning);
|
||||||
|
RefreshVariantNames();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectionStore.Variants.Remove(oldName);
|
||||||
|
_selectionStore.Variants[newName] = selection;
|
||||||
|
await WriteSelectionStoreAsync();
|
||||||
|
RefreshVariantNames();
|
||||||
|
_selectedVariantName = newName;
|
||||||
|
_variantName = newName;
|
||||||
|
Snackbar.Add($"{T("Variante umbenannt", "Variant renamed")}: {oldName} -> {newName}", Severity.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadVariantAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_selectedVariantName))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_selectionStore = await ReadSelectionStoreAsync();
|
||||||
|
if (!_selectionStore.Variants.TryGetValue(_selectedVariantName.Trim(), out var selection))
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Variante nicht gefunden.", "Variant not found."), Severity.Warning);
|
||||||
|
RefreshVariantNames();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplySelectionState(selection);
|
||||||
|
_variantName = _selectedVariantName;
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteVariantAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_selectedVariantName))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var name = _selectedVariantName.Trim();
|
||||||
|
_selectionStore = await ReadSelectionStoreAsync();
|
||||||
|
_selectionStore.Variants.Remove(name);
|
||||||
|
await WriteSelectionStoreAsync();
|
||||||
|
RefreshVariantNames();
|
||||||
|
_selectedVariantName = null;
|
||||||
|
_variantName = null;
|
||||||
|
Snackbar.Add($"{T("Variante gelöscht", "Variant deleted")}: {name}", Severity.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshVariantNames()
|
||||||
|
{
|
||||||
|
_variantNames = _selectionStore.Variants.Keys
|
||||||
|
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private HrKpiSelectionState CreateSelectionState()
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
DataFolder = _dataFolder,
|
||||||
|
Year = _year,
|
||||||
|
FromDate = _fromDate,
|
||||||
|
ToDate = _toDate,
|
||||||
|
EntryYear = _entryYear,
|
||||||
|
Organisation = _organisation,
|
||||||
|
Kostenstelle = _kostenstelle,
|
||||||
|
Mitarbeitertyp = _mitarbeitertyp,
|
||||||
|
FluktuationFilter = _fluktuationFilter,
|
||||||
|
GlzAmpel = _glzAmpel,
|
||||||
|
RestferienAmpel = _restferienAmpel,
|
||||||
|
SearchText = _searchText,
|
||||||
|
ManagementView = _managementView
|
||||||
|
};
|
||||||
|
|
||||||
|
private void ApplySelectionState(HrKpiSelectionState state)
|
||||||
|
{
|
||||||
|
_dataFolder = string.IsNullOrWhiteSpace(state.DataFolder) ? _serverUploadFolder : state.DataFolder;
|
||||||
|
_year = state.Year;
|
||||||
|
_fromDate = state.FromDate;
|
||||||
|
_toDate = state.ToDate;
|
||||||
|
_entryYear = state.EntryYear;
|
||||||
|
_organisation = state.Organisation;
|
||||||
|
_kostenstelle = state.Kostenstelle;
|
||||||
|
_mitarbeitertyp = string.IsNullOrWhiteSpace(state.Mitarbeitertyp) ? "Festangestellt" : state.Mitarbeitertyp;
|
||||||
|
_fluktuationFilter = string.IsNullOrWhiteSpace(state.FluktuationFilter) ? "Alle" : state.FluktuationFilter;
|
||||||
|
_glzAmpel = state.GlzAmpel;
|
||||||
|
_restferienAmpel = state.RestferienAmpel;
|
||||||
|
_searchText = state.SearchText;
|
||||||
|
_managementView = state.ManagementView;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<HrKpiSelectionStore> ReadSelectionStoreAsync()
|
||||||
|
{
|
||||||
|
await SelectionStoreLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(_selectionStorePath))
|
||||||
|
return new HrKpiSelectionStore();
|
||||||
|
|
||||||
|
await using var stream = File.OpenRead(_selectionStorePath);
|
||||||
|
return await JsonSerializer.DeserializeAsync<HrKpiSelectionStore>(stream) ?? new HrKpiSelectionStore();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new HrKpiSelectionStore();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SelectionStoreLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WriteSelectionStoreAsync()
|
||||||
|
{
|
||||||
|
await SelectionStoreLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(_selectionStorePath) ?? _serverUploadFolder);
|
||||||
|
var options = new JsonSerializerOptions { WriteIndented = true };
|
||||||
|
await using var stream = File.Create(_selectionStorePath);
|
||||||
|
await JsonSerializer.SerializeAsync(stream, _selectionStore, options);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SelectionStoreLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UnlockHrKpiAsync()
|
||||||
|
{
|
||||||
|
_unlockClickCount++;
|
||||||
|
|
||||||
|
if (!HrKpiAccess.TryUnlock(_hrUsername ?? string.Empty, _hrPassword ?? string.Empty))
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("HR-KPI-Anmeldung fehlgeschlagen.", "HR KPI sign-in failed."), Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_hrPassword = string.Empty;
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task ChangeHrPasswordAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_newPassword) || _newPassword.Length < 8)
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Das neue Passwort muss mindestens 8 Zeichen lang sein.", "The new password must be at least 8 characters long."), Severity.Warning);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_newPassword != _newPasswordRepeat)
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Die neuen Passwörter stimmen nicht überein.", "The new passwords do not match."), Severity.Warning);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!HrKpiAccess.TryChangePassword(_changeUsername ?? string.Empty, _currentPassword ?? string.Empty, _newPassword))
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Passwort konnte nicht geändert werden. Name oder aktuelles Passwort prüfen.", "Password could not be changed. Check the name or current password."), Severity.Error);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentPassword = string.Empty;
|
||||||
|
_newPassword = string.Empty;
|
||||||
|
_newPasswordRepeat = string.Empty;
|
||||||
|
Snackbar.Add(T("Passwort wurde geändert.", "Password has been changed."), Severity.Success);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LockHrKpi()
|
||||||
|
{
|
||||||
|
HrKpiAccess.Lock();
|
||||||
|
_result = null;
|
||||||
|
_hrPassword = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PrintAsync()
|
||||||
|
{
|
||||||
|
await JsRuntime.InvokeVoidAsync("print");
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanShowHrKpi => !HrKpiAccess.IsEnabled || HrKpiAccess.IsUnlocked;
|
||||||
|
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
|
||||||
|
private sealed class HrKpiSelectionStore
|
||||||
|
{
|
||||||
|
public HrKpiSelectionState? LastSelection { get; set; }
|
||||||
|
public Dictionary<string, HrKpiSelectionState> Variants { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class HrKpiSelectionState
|
||||||
|
{
|
||||||
|
public string? DataFolder { get; set; }
|
||||||
|
public int? Year { get; set; }
|
||||||
|
public DateTime? FromDate { get; set; }
|
||||||
|
public DateTime? ToDate { get; set; }
|
||||||
|
public int? EntryYear { get; set; }
|
||||||
|
public string? Organisation { get; set; }
|
||||||
|
public string? Kostenstelle { get; set; }
|
||||||
|
public string? Mitarbeitertyp { get; set; }
|
||||||
|
public string? FluktuationFilter { get; set; }
|
||||||
|
public string? GlzAmpel { get; set; }
|
||||||
|
public string? RestferienAmpel { get; set; }
|
||||||
|
public string? SearchText { get; set; }
|
||||||
|
public bool ManagementView { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
@page "/hr-kpi/schulung"
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
|
@inject TrafagSalesExporter.Services.IUiTextService UiText
|
||||||
|
|
||||||
|
<PageTitle>@T("HR KPI Schulung", "HR KPI training")</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-2">@T("HR KPI Schulung", "HR KPI training")</MudText>
|
||||||
|
<MudText Typo="Typo.body1" Class="mb-4 training-lead">
|
||||||
|
Ausführliche Anwenderunterlage für HR-Keyuser, HR-Leitung und Stellvertretungen. Die Seite erklärt nicht nur die Bedienung,
|
||||||
|
sondern auch die Reihenfolge, die Prüfpflichten und die fachliche Interpretation der wichtigsten Kennzahlen.
|
||||||
|
</MudText>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="7">
|
||||||
|
<img class="training-hero-image" src="training/hr_kpi_cockpit_preview.png" alt="HR KPI Cockpit Vorschau" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="5">
|
||||||
|
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-3">
|
||||||
|
Diese Schulung ist als Arbeitsanleitung gedacht: erst Datenstatus prüfen, dann filtern, danach Kennzahlen interpretieren.
|
||||||
|
</MudAlert>
|
||||||
|
<MudSimpleTable Dense Hover>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Rolle</th><th>Schwerpunkt</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>HR Keyuser</td><td>Daten bereitstellen, Plausibilität prüfen, Monatsauswertung erstellen</td></tr>
|
||||||
|
<tr><td>HR Leitung</td><td>Kennzahlen bewerten, Managementsicht freigeben, Massnahmen ableiten</td></tr>
|
||||||
|
<tr><td>IT/Admin</td><td>Zugriff, Pfad, Konfiguration und technische Fehler klären</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudTabs Rounded Border>
|
||||||
|
<MudTabPanel Text="Überblick">
|
||||||
|
<TrainingSection Title="1. Zweck und Grundprinzip">
|
||||||
|
<p>Das HR KPI Cockpit bündelt operative HR-Auswertungen in einer geschützten Oberfläche. Es ersetzt keine fachliche Freigabe,
|
||||||
|
sondern liefert eine nachvollziehbare Sicht auf Headcount, Fluktuation, Absenzen, GLZ, Restferien und Datenqualität.</p>
|
||||||
|
<p>Die wichtigste Regel lautet: Eine Kennzahl ist nur so belastbar wie die Quelldateien, Filter und fachlichen Ausschlüsse,
|
||||||
|
die zu ihrer Berechnung verwendet wurden. Deshalb beginnt jede Auswertung mit dem Datenstatus.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Vor jeder Weitergabe den Reiter <strong>Datenstatus</strong> öffnen.</li>
|
||||||
|
<li>Rote Datenqualitätsmeldungen zuerst klären.</li>
|
||||||
|
<li>Filter und Zeitraum in Bericht oder E-Mail nennen.</li>
|
||||||
|
<li>Für Managementberichte personenbezogene Detailtabellen vermeiden.</li>
|
||||||
|
</ul>
|
||||||
|
</TrainingSection>
|
||||||
|
|
||||||
|
<TrainingSection Title="2. Bedienreihenfolge">
|
||||||
|
<div class="training-flow">
|
||||||
|
<div><span>1</span><strong>Dateien exportieren</strong><small>Rexx/SAP-Quellen aktualisieren</small></div>
|
||||||
|
<div><span>2</span><strong>Datenordner prüfen</strong><small>Pfad und Dateistand kontrollieren</small></div>
|
||||||
|
<div><span>3</span><strong>Laden</strong><small>Cockpit neu aufbauen</small></div>
|
||||||
|
<div><span>4</span><strong>Datenqualität</strong><small>Warnungen und Fehler lesen</small></div>
|
||||||
|
<div><span>5</span><strong>KPI freigeben</strong><small>Interpretieren und dokumentieren</small></div>
|
||||||
|
</div>
|
||||||
|
<p>Diese Reihenfolge verhindert, dass alte Dateien, leere Filter oder unvollständige Exporte als Managementzahlen verwendet werden.</p>
|
||||||
|
</TrainingSection>
|
||||||
|
|
||||||
|
<TrainingSection Title="3. Datenquellen und Dateistatus">
|
||||||
|
<MudSimpleTable Dense Hover>
|
||||||
|
<thead><tr><th>Datei</th><th>Inhalt</th><th>Prüfung</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Saldiperstichdatum.xlsx</td><td>Aktive Mitarbeitende, Saldi, Ferien, Organisation, Kostenstelle</td><td>Zeilenanzahl, Alter, Organisationsabdeckung</td></tr>
|
||||||
|
<tr><td>Exportkommengehen.xlsx</td><td>Arbeitszeitmodell, Sollzeit, Geburtsdatum</td><td>FTE-/Sollzeit-Fallback prüfen</td></tr>
|
||||||
|
<tr><td>HR_KPI_Export.xlsx</td><td>SAP-HR-Felder, Beschäftigungsgrad, Geschlecht, BU/NBU, Planstelle</td><td>Join auf Personalnummer plausibilisieren</td></tr>
|
||||||
|
<tr><td>Abwesenheitinstunden.xlsx</td><td>Krankheit kurz/lang, Unfall, Stundenwerte</td><td>Stunden-zu-Tage-Logik und Zeitraum prüfen</td></tr>
|
||||||
|
<tr><td>Personalausgeschieden.xlsx</td><td>Austritte, Austrittsart, Austrittsdatum</td><td>Austrittsarten und Ausschlüsse kontrollieren</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Class="mt-3">
|
||||||
|
PDF-Dateien sind für diese Auswertung ungeeignet. Die Dateien müssen als strukturierte Excel-Dateien vorliegen.
|
||||||
|
</MudAlert>
|
||||||
|
</TrainingSection>
|
||||||
|
</MudTabPanel>
|
||||||
|
|
||||||
|
<MudTabPanel Text="Auswertung">
|
||||||
|
<TrainingSection Title="4. Filter richtig verwenden">
|
||||||
|
<p>Filter sind fachliche Eingriffe in die Sicht. Deshalb muss bei jeder Auswertung klar sein, ob ein Filter nur aktive Mitarbeitende,
|
||||||
|
Austritte oder beide Datenbereiche betrifft.</p>
|
||||||
|
<MudSimpleTable Dense Hover>
|
||||||
|
<thead><tr><th>Filter</th><th>Wirkung</th><th>Hinweis</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Austrittsjahr</td><td>grenzt Austritte nach Jahr ein</td><td>Leer bedeutet alle verfügbaren Jahre</td></tr>
|
||||||
|
<tr><td>Von/Bis Austritt</td><td>hat Vorrang vor Austrittsjahr</td><td>Für Quartals- und Sonderauswertungen verwenden</td></tr>
|
||||||
|
<tr><td>Organisation</td><td>wirkt auf aktive Mitarbeitende und passende Austrittsdaten</td><td>Leere Organisationen sind Datenqualitätsthema</td></tr>
|
||||||
|
<tr><td>Kostenstelle</td><td>wirkt stabil auf aktive Mitarbeitende</td><td>Nicht jede Austrittsquelle enthält Kostenstellen sauber</td></tr>
|
||||||
|
<tr><td>GLZ/Restferien Ampel</td><td>fokussiert operative Prüffälle</td><td>Nicht als Fluktuationsfilter interpretieren</td></tr>
|
||||||
|
<tr><td>Managementsicht</td><td>reduziert personenbezogene Details</td><td>Für Weitergabe und Ausdruck bevorzugen</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
</TrainingSection>
|
||||||
|
|
||||||
|
<TrainingSection Title="5. Headcount und Mitarbeitende">
|
||||||
|
<p>Headcount ist die Basis fast aller HR-Kennzahlen. Sprünge gegenüber Vormonat oder Vorjahr müssen erklärbar sein,
|
||||||
|
zum Beispiel durch Eintritte, Austritte, Organisationswechsel oder geänderte Quelldateien.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Headcount nach Organisation zählt eindeutige Personalnummern.</li>
|
||||||
|
<li>Leere Personalnummern werden nicht als verlässlicher Distinct-Headcount verwendet.</li>
|
||||||
|
<li>FTE kann aus SAP-Beschäftigungsgrad oder aus Arbeitszeitmodell/Sollzeit abgeleitet werden.</li>
|
||||||
|
<li>Bei unerwarteten Sprüngen zuerst Datenstatus und Join-Hinweise prüfen.</li>
|
||||||
|
</ul>
|
||||||
|
</TrainingSection>
|
||||||
|
|
||||||
|
<TrainingSection Title="6. Fluktuation fachlich lesen">
|
||||||
|
<p>Die Fluktuation wird aus ausgeschiedenen Personen berechnet. Relevant sind vor allem Arbeitnehmerkündigungen.
|
||||||
|
Praktikanten, befristete Verträge, Pensionierungen und Arbeitgeberkündigungen werden für die relevante Fluktuation ausgeschlossen.</p>
|
||||||
|
<MudSimpleTable Dense Hover>
|
||||||
|
<thead><tr><th>Kennzahl</th><th>Berechnung</th><th>Interpretation</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Monatsfluktuation</td><td>relevante Austritte im Monat / Headcount des Monats</td><td>Operativer Frühindikator</td></tr>
|
||||||
|
<tr><td>Quartalsfluktuation</td><td>relevante Austritte im Quartal / durchschnittlicher Quartals-Headcount</td><td>Stabiler als Monatswert</td></tr>
|
||||||
|
<tr><td>Jahresfluktuation</td><td>relevante Austritte im Jahr / durchschnittlicher Jahres-Headcount</td><td>Management-KPI</td></tr>
|
||||||
|
<tr><td>Hochrechnung</td><td>aktuelle Quartalsfluktuation x 4</td><td>Nur als Prognose lesen</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
</TrainingSection>
|
||||||
|
|
||||||
|
<TrainingSection Title="7. Absenzen, GLZ und Restferien">
|
||||||
|
<p>Absenzen werden nach Stunden/Tagen und Organisation ausgewertet. Die Krankenquote nutzt Krankheitstage im Verhältnis
|
||||||
|
zu FTE und Arbeitstagen. Unfalltage können je nach Quelle anders abgegrenzt sein und müssen daher vorsichtig interpretiert werden.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Top-Absenzen dienen der operativen Prüfung, nicht der direkten Weitergabe.</li>
|
||||||
|
<li>Kritische Restferien zeigen Planungsbedarf oder Datenfehler.</li>
|
||||||
|
<li>Kritische GLZ-Saldi sollten mit Linienverantwortlichen geprüft werden.</li>
|
||||||
|
<li>Gelbe Ampeln sind Beobachtungspunkte, rote Ampeln brauchen aktive Klärung.</li>
|
||||||
|
</ul>
|
||||||
|
</TrainingSection>
|
||||||
|
</MudTabPanel>
|
||||||
|
|
||||||
|
<MudTabPanel Text="Qualität & Freigabe">
|
||||||
|
<TrainingSection Title="8. Datenqualität systematisch prüfen">
|
||||||
|
<MudSimpleTable Dense Hover>
|
||||||
|
<thead><tr><th>Fehlerbild</th><th>Mögliche Ursache</th><th>Massnahme</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Keine Daten</td><td>Datei fehlt, falscher Ordner, falscher Dateiname</td><td>Pfad korrigieren und neu laden</td></tr>
|
||||||
|
<tr><td>Alter Dateistand</td><td>Export wurde nicht erneuert</td><td>Quelle neu exportieren</td></tr>
|
||||||
|
<tr><td>Leere Organisation</td><td>Join oder Stammdatenfeld fehlt</td><td>Quelldaten und Personalnummer prüfen</td></tr>
|
||||||
|
<tr><td>Sprung im Headcount</td><td>neuer Export, Filter, Stichtagswechsel</td><td>Vorperiode und Dateistatus vergleichen</td></tr>
|
||||||
|
<tr><td>Ungewöhnliche Absenzen</td><td>Stundenlogik, Zeitraum, Doppelerfassung</td><td>Einzelzeilen und Quelle prüfen</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
</TrainingSection>
|
||||||
|
|
||||||
|
<TrainingSection Title="9. Managementsicht und Datenschutz">
|
||||||
|
<p>HR-Daten enthalten personenbezogene Informationen. Für Berichte an Management oder Dritte ist die Managementsicht zu bevorzugen.
|
||||||
|
Detailtabellen mit Namen, Personalnummern oder Einzelfällen dürfen nur an berechtigte Personen gehen.</p>
|
||||||
|
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined">
|
||||||
|
Keine HR-Dateien per E-Mail weiterleiten und keine Kopien in ungeschützten Ordnern liegen lassen.
|
||||||
|
</MudAlert>
|
||||||
|
</TrainingSection>
|
||||||
|
|
||||||
|
<TrainingSection Title="10. Monatsabschluss-Checkliste">
|
||||||
|
<MudSimpleTable Dense Hover>
|
||||||
|
<thead><tr><th>Nr.</th><th>Prüfpunkt</th><th>Erledigt</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>1</td><td>Alle HR-Quelldateien neu exportiert und im richtigen Ordner abgelegt</td><td></td></tr>
|
||||||
|
<tr><td>2</td><td>Cockpit neu geladen, keine roten Dateistatusmeldungen</td><td></td></tr>
|
||||||
|
<tr><td>3</td><td>Headcount gegen Vorperiode plausibilisiert</td><td></td></tr>
|
||||||
|
<tr><td>4</td><td>Fluktuationsausschlüsse fachlich geprüft</td><td></td></tr>
|
||||||
|
<tr><td>5</td><td>Absenzen, GLZ und Restferien auf Ausreisser geprüft</td><td></td></tr>
|
||||||
|
<tr><td>6</td><td>Filter, Zeitraum und Datenstand im Bericht dokumentiert</td><td></td></tr>
|
||||||
|
<tr><td>7</td><td>Managementsicht für Weitergabe verwendet</td><td></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
</TrainingSection>
|
||||||
|
</MudTabPanel>
|
||||||
|
</MudTabs>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.training-lead { max-width: 980px; color: var(--mud-palette-text-secondary); }
|
||||||
|
.training-hero-image { width: 100%; border: 1px solid var(--mud-palette-lines-default); border-radius: 6px; }
|
||||||
|
.training-section { margin: 18px 0 28px; max-width: 1180px; }
|
||||||
|
.training-section p { margin-bottom: 12px; line-height: 1.55; }
|
||||||
|
.training-section ul { margin-top: 8px; }
|
||||||
|
.training-flow { display: grid; grid-template-columns: repeat(5, minmax(120px, 1fr)); gap: 10px; margin: 12px 0 18px; }
|
||||||
|
.training-flow div { border: 1px solid var(--mud-palette-lines-default); border-radius: 6px; padding: 12px; background: var(--mud-palette-surface); }
|
||||||
|
.training-flow span { display: inline-flex; width: 28px; height: 28px; align-items: center; justify-content: center; border-radius: 50%; background: var(--mud-palette-primary); color: var(--mud-palette-primary-text); font-weight: 700; margin-bottom: 8px; }
|
||||||
|
.training-flow strong, .training-flow small { display: block; }
|
||||||
|
.training-flow small { color: var(--mud-palette-text-secondary); margin-top: 4px; }
|
||||||
|
@@media (max-width: 900px) { .training-flow { grid-template-columns: 1fr; } }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
@page "/diagnostics/interactive"
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
|
@inject ILogger<InteractiveDiagnostics> Logger
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
|
<PageTitle>Interaktivitaet Diagnose</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-4">Interaktivitaet Diagnose</MudText>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4" Elevation="1" Style="max-width:760px;">
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined">
|
||||||
|
HTML wurde vom Server gerendert.
|
||||||
|
</MudAlert>
|
||||||
|
|
||||||
|
<MudText>Adresse: @Navigation.Uri</MudText>
|
||||||
|
<MudText>Blazor interaktiv verbunden: @(_interactive ? "JA" : "NEIN")</MudText>
|
||||||
|
<MudText>Server-Klicks angekommen: @_clickCount</MudText>
|
||||||
|
<MudText>JavaScript Diagnose: <span id="js-diagnostic-status">nicht ausgefuehrt</span></MudText>
|
||||||
|
<MudText>Blazor Objekt im Browser: <span id="blazor-diagnostic-status">unbekannt</span></MudText>
|
||||||
|
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="RegisterClick">
|
||||||
|
Server-Klick testen
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var jsStatus = document.getElementById('js-diagnostic-status');
|
||||||
|
var blazorStatus = document.getElementById('blazor-diagnostic-status');
|
||||||
|
if (jsStatus) {
|
||||||
|
jsStatus.textContent = 'ausgefuehrt';
|
||||||
|
}
|
||||||
|
if (blazorStatus) {
|
||||||
|
blazorStatus.textContent = window.Blazor ? 'vorhanden' : 'fehlt';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private bool _interactive;
|
||||||
|
private int _clickCount;
|
||||||
|
|
||||||
|
protected override void OnAfterRender(bool firstRender)
|
||||||
|
{
|
||||||
|
if (!firstRender)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_interactive = true;
|
||||||
|
Logger.LogInformation("Interactive diagnostics became interactive for {Uri}", Navigation.Uri);
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RegisterClick()
|
||||||
|
{
|
||||||
|
_clickCount++;
|
||||||
|
Logger.LogInformation("Interactive diagnostics server click received. Count={ClickCount}", _clickCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
@page "/logs"
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
|
@using TrafagSalesExporter.Services
|
||||||
|
@inject ILogsPageService LogsPageActions
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@inject TrafagSalesExporter.Services.IUiTextService UiText
|
||||||
|
|
||||||
|
<PageTitle>@T("Logs", "Logs")</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-4">@T("Export Logs", "Export Logs")</MudText>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
||||||
|
<MudStack Row AlignItems="AlignItems.Center" Spacing="3">
|
||||||
|
<MudSelect @bind-Value="_filterLand" Label="@T("Land", "Country")" Clearable Dense Style="max-width:200px;">
|
||||||
|
@foreach (var land in _availableLands)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@land">@land</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
<MudSelect @bind-Value="_filterStatus" Label="@T("Status", "Status")" Clearable Dense Style="max-width:150px;">
|
||||||
|
<MudSelectItem Value="@("OK")">OK</MudSelectItem>
|
||||||
|
<MudSelectItem Value="@("Error")">Error</MudSelectItem>
|
||||||
|
</MudSelect>
|
||||||
|
<MudDatePicker @bind-Date="_filterDate" Label="@T("Datum", "Date")" Clearable Dense Style="max-width:200px;" />
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="ApplyFilter"
|
||||||
|
StartIcon="@Icons.Material.Filled.FilterAlt">
|
||||||
|
@T("Filtern", "Filter")
|
||||||
|
</MudButton>
|
||||||
|
<MudSpacer />
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="DeleteOldLogs"
|
||||||
|
StartIcon="@Icons.Material.Filled.DeleteSweep">
|
||||||
|
@T("Alte Logs loeschen", "Delete old logs")
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudTable Items="_logs" Dense Hover Striped Loading="_loading">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Zeitpunkt", "Timestamp")</MudTh>
|
||||||
|
<MudTh>@T("Land", "Country")</MudTh>
|
||||||
|
<MudTh>TSC</MudTh>
|
||||||
|
<MudTh>@T("Status", "Status")</MudTh>
|
||||||
|
<MudTh>@T("Zeilen", "Rows")</MudTh>
|
||||||
|
<MudTh>@T("Dauer", "Duration")</MudTh>
|
||||||
|
<MudTh>@T("Dateiname", "File name")</MudTh>
|
||||||
|
<MudTh>@T("Fehler", "Error")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Timestamp.ToString("dd.MM.yyyy HH:mm:ss")</MudTd>
|
||||||
|
<MudTd>@context.Land</MudTd>
|
||||||
|
<MudTd>@context.TSC</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@if (context.Status == "OK")
|
||||||
|
{
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="Color.Success">OK</MudChip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="Color.Error">Error</MudChip>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>@context.RowCount.ToString("N0")</MudTd>
|
||||||
|
<MudTd>@($"{context.DurationSeconds:F1}s")</MudTd>
|
||||||
|
<MudTd>@context.FileName</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@if (!string.IsNullOrEmpty(context.ErrorMessage))
|
||||||
|
{
|
||||||
|
<MudTooltip Text="@context.ErrorMessage">
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Error" Style="max-width:300px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block;">
|
||||||
|
@context.ErrorMessage
|
||||||
|
</MudText>
|
||||||
|
</MudTooltip>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h5" Class="mt-6 mb-2">@T("Technische Logs", "Technical logs")</MudText>
|
||||||
|
|
||||||
|
<MudTable Items="_appLogs" Dense Hover Striped Loading="_loading">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Zeitpunkt", "Timestamp")</MudTh>
|
||||||
|
<MudTh>Level</MudTh>
|
||||||
|
<MudTh>@T("Kategorie", "Category")</MudTh>
|
||||||
|
<MudTh>@T("Land", "Country")</MudTh>
|
||||||
|
<MudTh>@T("Meldung", "Message")</MudTh>
|
||||||
|
<MudTh>Details</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Timestamp.ToString("dd.MM.yyyy HH:mm:ss")</MudTd>
|
||||||
|
<MudTd>@context.Level</MudTd>
|
||||||
|
<MudTd>@context.Category</MudTd>
|
||||||
|
<MudTd>@(string.IsNullOrWhiteSpace(context.Land) ? "-" : context.Land)</MudTd>
|
||||||
|
<MudTd>@context.Message</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(context.Details))
|
||||||
|
{
|
||||||
|
<MudTooltip Text="@context.Details">
|
||||||
|
<MudText Typo="Typo.caption" Style="max-width:420px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block;">
|
||||||
|
@context.Details
|
||||||
|
</MudText>
|
||||||
|
</MudTooltip>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<ExportLog> _logs = new();
|
||||||
|
private List<AppEventLog> _appLogs = new();
|
||||||
|
private List<string> _availableLands = new();
|
||||||
|
private string? _filterLand;
|
||||||
|
private string? _filterStatus;
|
||||||
|
private DateTime? _filterDate;
|
||||||
|
private bool _loading = true;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadLogsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadLogsAsync()
|
||||||
|
{
|
||||||
|
_loading = true;
|
||||||
|
var state = await LogsPageActions.LoadAsync(_filterLand, _filterStatus, _filterDate);
|
||||||
|
_availableLands = state.AvailableLands;
|
||||||
|
_logs = state.Logs;
|
||||||
|
_appLogs = state.AppLogs;
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ApplyFilter()
|
||||||
|
{
|
||||||
|
await LoadLogsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteOldLogs()
|
||||||
|
{
|
||||||
|
var result = await DialogService.ShowMessageBox(
|
||||||
|
T("Alte Logs loeschen", "Delete old logs"),
|
||||||
|
T("Logs aelter als 90 Tage loeschen?", "Delete logs older than 90 days?"),
|
||||||
|
yesText: T("Loeschen", "Delete"), cancelText: T("Abbrechen", "Cancel"));
|
||||||
|
|
||||||
|
if (result != true) return;
|
||||||
|
|
||||||
|
var deletedCount = await LogsPageActions.DeleteOldLogsAsync(90);
|
||||||
|
await LoadLogsAsync();
|
||||||
|
Snackbar.Add(string.Format(T("{0} alte Logs geloescht", "{0} old logs deleted"), deletedCount), Severity.Info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,368 @@
|
|||||||
|
@page "/manual-imports"
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using TrafagSalesExporter.Data
|
||||||
|
@using TrafagSalesExporter.Models
|
||||||
|
@using TrafagSalesExporter.Services
|
||||||
|
@inject IDbContextFactory<AppDbContext> DbFactory
|
||||||
|
@inject IStandortePageService StandortePageService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IUiTextService UiText
|
||||||
|
|
||||||
|
<PageTitle>@T("Manuelle Importe", "Manual imports")</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-4">@T("Manuelle Importe", "Manual imports")</MudText>
|
||||||
|
|
||||||
|
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense Class="mb-4">
|
||||||
|
@T("Diese Seite ist fuer Keyuser: Hier werden Excel-/CSV-Dateien fuer manuelle Laender wie DE, UK und ES hinterlegt und aktiviert. Technische Spaltenmappings bleiben in Admin -> Standorte.",
|
||||||
|
"This page is for key users: Excel/CSV files for manual countries such as DE, UK and ES are maintained and activated here. Technical column mappings remain in Admin -> Sites.")
|
||||||
|
</MudAlert>
|
||||||
|
|
||||||
|
<MudTabs Elevation="0" Rounded="false" PanelClass="manual-import-tab-panel">
|
||||||
|
<MudTabPanel Text="@T("Importdateien", "Import files")" Icon="@Icons.Material.Filled.UploadFile">
|
||||||
|
<MudTable Items="_rows" Dense Hover Striped Loading="_loading">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Land", "Country")</MudTh>
|
||||||
|
<MudTh>TSC</MudTh>
|
||||||
|
<MudTh>@T("Aktiv", "Active")</MudTh>
|
||||||
|
<MudTh>@T("Datei / SharePoint-Ordner", "File / SharePoint folder")</MudTh>
|
||||||
|
<MudTh>@T("Letzter Upload", "Last upload")</MudTh>
|
||||||
|
<MudTh>@T("Aktionen", "Actions")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Land</MudTd>
|
||||||
|
<MudTd>@context.TSC</MudTd>
|
||||||
|
<MudTd><MudSwitch @bind-Value="context.IsActive" Color="Color.Primary" /></MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudTextField @bind-Value="context.ManualImportFilePath"
|
||||||
|
Placeholder="@T("lokaler Pfad, UNC, SharePoint-Datei oder SharePoint-Ordner", "local path, UNC, SharePoint file or SharePoint folder")"
|
||||||
|
Margin="Margin.Dense" />
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>@(context.ManualImportLastUploadedAtUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss") ?? "-")</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudStack Row Spacing="1">
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Info"
|
||||||
|
StartIcon="@Icons.Material.Filled.FactCheck"
|
||||||
|
OnClick="() => ValidatePathAsync(context)" Disabled="_busySiteId == context.Id">
|
||||||
|
@T("Pfad pruefen", "Check path")
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.Save"
|
||||||
|
OnClick="() => SaveAsync(context)" Disabled="_busySiteId == context.Id">
|
||||||
|
@T("Speichern", "Save")
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
<InputFile OnChange="args => UploadAsync(context, args)" accept=".xlsx,.csv" />
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
<NoRecordsContent>
|
||||||
|
<MudText Typo="Typo.caption">@T("Keine manuellen Excel-/CSV-Standorte gefunden.", "No manual Excel/CSV sites found.")</MudText>
|
||||||
|
</NoRecordsContent>
|
||||||
|
</MudTable>
|
||||||
|
</MudTabPanel>
|
||||||
|
<MudTabPanel Text="@T("Anleitung", "Guide")" Icon="@Icons.Material.Filled.Route">
|
||||||
|
<div class="workflow-shell">
|
||||||
|
<div class="workflow-step import">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.UploadFile" Size="Size.Large" />
|
||||||
|
<span class="workflow-index">1</span>
|
||||||
|
<h3>@T("Excel bereitstellen", "Provide Excel")</h3>
|
||||||
|
<p>@T("Datei hochladen oder SharePoint-/UNC-Pfad eintragen.", "Upload a file or enter a SharePoint/UNC path.")</p>
|
||||||
|
</div>
|
||||||
|
<div class="workflow-arrow">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.ArrowForward" />
|
||||||
|
</div>
|
||||||
|
<div class="workflow-step save">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Save" Size="Size.Large" />
|
||||||
|
<span class="workflow-index">2</span>
|
||||||
|
<h3>@T("Speichern und aktivieren", "Save and activate")</h3>
|
||||||
|
<p>@T("Pfad pruefen, Standort aktiv setzen und speichern.", "Check the path, set the site active, and save.")</p>
|
||||||
|
</div>
|
||||||
|
<div class="workflow-arrow">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.ArrowForward" />
|
||||||
|
</div>
|
||||||
|
<div class="workflow-step export">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.PlayArrow" Size="Size.Large" />
|
||||||
|
<span class="workflow-index">3</span>
|
||||||
|
<h3>@T("Standort exportieren", "Export site")</h3>
|
||||||
|
<p>@T("Im Export Dashboard den Standort starten. Die Daten landen in CentralSalesRecords.", "Start the site in the export dashboard. Data is written to CentralSalesRecords.")</p>
|
||||||
|
</div>
|
||||||
|
<div class="workflow-arrow">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.ArrowForward" />
|
||||||
|
</div>
|
||||||
|
<div class="workflow-step central">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.TableView" Size="Size.Large" />
|
||||||
|
<span class="workflow-index">4</span>
|
||||||
|
<h3>@T("Zentrale Excel erzeugen", "Build final Excel")</h3>
|
||||||
|
<p>@T("Danach `Zentrale Datei neu erzeugen` ausfuehren.", "Then run `Rebuild consolidated file`.")</p>
|
||||||
|
</div>
|
||||||
|
<div class="workflow-arrow">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.ArrowForward" />
|
||||||
|
</div>
|
||||||
|
<div class="workflow-step check">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.CompareArrows" Size="Size.Large" />
|
||||||
|
<span class="workflow-index">5</span>
|
||||||
|
<h3>@T("Finance pruefen", "Check finance")</h3>
|
||||||
|
<p>@T("Im Endexcel `Finance | ...` oder im Reiter `Soll/Ist Vergleich` kontrollieren.", "Check the `Finance | ...` columns in the final Excel or the `Actual/reference comparison` tab.")</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="workflow-notes">
|
||||||
|
<div class="workflow-note good">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" />
|
||||||
|
<div>
|
||||||
|
<strong>@T("Richtige Reihenfolge", "Correct order")</strong>
|
||||||
|
<p>@T("Ein Standortexport aktualisiert die Datenbasis. Die zentrale Excel muss danach neu erzeugt werden.", "A site export updates the data basis. The final Excel must be rebuilt afterwards.")</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="workflow-note warn">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Warning" />
|
||||||
|
<div>
|
||||||
|
<strong>@T("DE bleibt fachlich offen", "DE remains open")</strong>
|
||||||
|
<p>@T("Alphaplan ist technisch importierbar. Kundenlaender und Filter fuer den offiziellen DE-Istwert muessen noch bestaetigt werden.", "Alphaplan is technically importable. Customer countries and filters for the official DE actual still need confirmation.")</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="workflow-note info">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Info" />
|
||||||
|
<div>
|
||||||
|
<strong>@T("Server-Hinweis", "Server note")</strong>
|
||||||
|
<p>@T("Der Server braucht kein Microsoft Excel. XLSX/CSV wird direkt von der Anwendung gelesen.", "The server does not need Microsoft Excel. XLSX/CSV is read directly by the application.")</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MudTabPanel>
|
||||||
|
</MudTabs>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.manual-import-tab-panel {
|
||||||
|
padding-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(150px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-step {
|
||||||
|
position: relative;
|
||||||
|
min-height: 190px;
|
||||||
|
padding: 18px 16px;
|
||||||
|
border: 1px solid var(--mud-palette-lines-default);
|
||||||
|
background: var(--mud-palette-surface);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-step.import { border-top: 5px solid var(--mud-palette-info); }
|
||||||
|
.workflow-step.save { border-top: 5px solid var(--mud-palette-primary); }
|
||||||
|
.workflow-step.export { border-top: 5px solid var(--mud-palette-success); }
|
||||||
|
.workflow-step.central { border-top: 5px solid var(--mud-palette-secondary); }
|
||||||
|
.workflow-step.check { border-top: 5px solid var(--mud-palette-warning); }
|
||||||
|
|
||||||
|
.workflow-step h3 {
|
||||||
|
margin: 6px 0 0 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-step p,
|
||||||
|
.workflow-note p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--mud-palette-text-secondary);
|
||||||
|
font-size: .9rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-index {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
right: 14px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--mud-palette-dark);
|
||||||
|
color: var(--mud-palette-dark-text);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-arrow {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-notes {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-note {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 34px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--mud-palette-lines-default);
|
||||||
|
background: var(--mud-palette-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-note.good { border-left: 5px solid var(--mud-palette-success); }
|
||||||
|
.workflow-note.warn { border-left: 5px solid var(--mud-palette-warning); }
|
||||||
|
.workflow-note.info { border-left: 5px solid var(--mud-palette-info); }
|
||||||
|
|
||||||
|
@@media (max-width: 1100px) {
|
||||||
|
.workflow-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-arrow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-notes {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<ManualImportRow> _rows = [];
|
||||||
|
private bool _loading = true;
|
||||||
|
private int? _busySiteId;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
_loading = true;
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
var manualSourceCodes = await db.SourceSystemDefinitions
|
||||||
|
.Where(x => x.ConnectionKind == SourceSystemConnectionKinds.ManualExcel)
|
||||||
|
.Select(x => x.Code)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
_rows = await db.Sites
|
||||||
|
.Where(site => manualSourceCodes.Contains(site.SourceSystem))
|
||||||
|
.OrderBy(site => site.Land)
|
||||||
|
.ThenBy(site => site.TSC)
|
||||||
|
.Select(site => new ManualImportRow
|
||||||
|
{
|
||||||
|
Id = site.Id,
|
||||||
|
Land = site.Land,
|
||||||
|
TSC = site.TSC,
|
||||||
|
IsActive = site.IsActive,
|
||||||
|
ManualImportFilePath = site.ManualImportFilePath,
|
||||||
|
ManualImportLastUploadedAtUtc = site.ManualImportLastUploadedAtUtc
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAsync(ManualImportRow row)
|
||||||
|
{
|
||||||
|
_busySiteId = row.Id;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
var site = await db.Sites.FirstAsync(x => x.Id == row.Id);
|
||||||
|
site.IsActive = row.IsActive;
|
||||||
|
site.ManualImportFilePath = row.ManualImportFilePath.Trim();
|
||||||
|
site.ManualImportLastUploadedAtUtc = row.ManualImportLastUploadedAtUtc;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
Snackbar.Add(T("Import-Einstellungen gespeichert.", "Import settings saved."), Severity.Success);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"{T("Speichern fehlgeschlagen", "Save failed")}: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_busySiteId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ValidatePathAsync(ManualImportRow row)
|
||||||
|
{
|
||||||
|
_busySiteId = row.Id;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
row.ManualImportLastUploadedAtUtc = await StandortePageService.ValidateManualImportPathAsync(row.ManualImportFilePath);
|
||||||
|
Snackbar.Add(T("Datei oder SharePoint-Referenz ist erreichbar.", "File or SharePoint reference is reachable."), Severity.Success);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"{T("Pfadpruefung fehlgeschlagen", "Path check failed")}: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_busySiteId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UploadAsync(ManualImportRow row, InputFileChangeEventArgs args)
|
||||||
|
{
|
||||||
|
var file = args.File;
|
||||||
|
if (file is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_busySiteId = row.Id;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var extension = Path.GetExtension(file.Name);
|
||||||
|
if (!string.Equals(extension, ".xlsx", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!string.Equals(extension, ".csv", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(T("Bitte eine .xlsx- oder .csv-Datei auswaehlen.", "Please choose a .xlsx or .csv file."));
|
||||||
|
}
|
||||||
|
|
||||||
|
var uploadDirectory = Path.Combine(AppContext.BaseDirectory, "manual-imports");
|
||||||
|
Directory.CreateDirectory(uploadDirectory);
|
||||||
|
var safeBaseName = string.Concat(Path.GetFileNameWithoutExtension(file.Name)
|
||||||
|
.Select(ch => char.IsLetterOrDigit(ch) || ch == '-' || ch == '_' ? ch : '_'));
|
||||||
|
if (string.IsNullOrWhiteSpace(safeBaseName))
|
||||||
|
safeBaseName = "manual_import";
|
||||||
|
|
||||||
|
var targetPath = Path.Combine(uploadDirectory, $"{safeBaseName}_{Guid.NewGuid():N}{extension}");
|
||||||
|
await using (var sourceStream = file.OpenReadStream(maxAllowedSize: 50 * 1024 * 1024))
|
||||||
|
await using (var targetStream = File.Create(targetPath))
|
||||||
|
{
|
||||||
|
await sourceStream.CopyToAsync(targetStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
row.ManualImportFilePath = targetPath;
|
||||||
|
row.ManualImportLastUploadedAtUtc = DateTime.UtcNow;
|
||||||
|
await SaveAsync(row);
|
||||||
|
Snackbar.Add(T("Datei hochgeladen.", "File uploaded."), Severity.Success);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"{T("Upload fehlgeschlagen", "Upload failed")}: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_busySiteId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
|
||||||
|
private sealed class ManualImportRow
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Land { get; set; } = string.Empty;
|
||||||
|
public string TSC { get; set; } = string.Empty;
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public string ManualImportFilePath { get; set; } = string.Empty;
|
||||||
|
public DateTime? ManualImportLastUploadedAtUtc { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
@page "/admin/menu-structure"
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@using TrafagSalesExporter.Models
|
||||||
|
@using TrafagSalesExporter.Security
|
||||||
|
@using TrafagSalesExporter.Services
|
||||||
|
@attribute [Authorize(Policy = SecurityPolicies.AdminOnly)]
|
||||||
|
@inject INavigationMenuService NavigationMenuService
|
||||||
|
@inject IUiTextService UiText
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
<PageTitle>@T("Menuestruktur", "Menu structure")</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-4">@T("Menuestruktur", "Menu structure")</MudText>
|
||||||
|
|
||||||
|
<MudStack Row="true" Spacing="2" Class="mb-3">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Save" OnClick="SaveAsync" Disabled="_loading">
|
||||||
|
@T("Speichern", "Save")
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Restore" OnClick="ResetAsync" Disabled="_loading">
|
||||||
|
@T("Standard wiederherstellen", "Restore default")
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
|
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-4">
|
||||||
|
@T("Bestehende Menuepunkte koennen in andere Gruppen gehaengt, sortiert, aus- oder eingeblendet und umbenannt werden. Die Zielseite bleibt unveraendert.",
|
||||||
|
"Existing menu entries can be moved into other groups, sorted, hidden/shown and renamed. The target page stays unchanged.")
|
||||||
|
</MudAlert>
|
||||||
|
|
||||||
|
@if (_loading)
|
||||||
|
{
|
||||||
|
<MudProgressCircular Indeterminate="true" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Drag & Drop", "Drag & drop")</MudText>
|
||||||
|
<div class="menu-drop-root"
|
||||||
|
@ondragover:preventDefault
|
||||||
|
@ondrop="DropOnRoot">
|
||||||
|
@T("Hier ablegen fuer Hauptmenue", "Drop here for root menu")
|
||||||
|
</div>
|
||||||
|
<MudPaper Class="pa-2 mb-4" Outlined="true">
|
||||||
|
@foreach (var item in OrderedItems)
|
||||||
|
{
|
||||||
|
<div class="menu-drag-row"
|
||||||
|
draggable="true"
|
||||||
|
@ondragstart="() => StartDrag(item)"
|
||||||
|
@ondragover:preventDefault
|
||||||
|
@ondrop="() => DropOn(item)">
|
||||||
|
<MudIcon Icon="@NavigationIconResolver.Resolve(item.Icon)" Size="Size.Small" />
|
||||||
|
<span class="menu-drag-title">@Indent(item)@item.TitleDe</span>
|
||||||
|
<span class="menu-drag-meta">@item.ItemType</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Details", "Details")</MudText>
|
||||||
|
<MudTable Items="@OrderedItems" Dense="true" Hover="true">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Reihenfolge", "Order")</MudTh>
|
||||||
|
<MudTh>@T("Titel", "Title")</MudTh>
|
||||||
|
<MudTh>@T("Typ", "Type")</MudTh>
|
||||||
|
<MudTh>@T("Untermenue von", "Parent")</MudTh>
|
||||||
|
<MudTh>@T("Sichtbar", "Visible")</MudTh>
|
||||||
|
<MudTh>@T("Aktion", "Action")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.KeyboardArrowUp" Size="Size.Small" OnClick="@(() => Move(context, -1))" />
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.KeyboardArrowDown" Size="Size.Small" OnClick="@(() => Move(context, 1))" />
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudTextField @bind-Value="context.TitleDe" Label="DE" Dense="true" Margin="Margin.Dense" />
|
||||||
|
<MudTextField @bind-Value="context.TitleEn" Label="EN" Dense="true" Margin="Margin.Dense" />
|
||||||
|
@if (!string.IsNullOrWhiteSpace(context.Href))
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption">@context.Href</MudText>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>@context.ItemType</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudSelect T="string" Value="@NormalizeParent(context.ParentKey)" ValueChanged="value => ChangeParent(context, value)" Dense="true">
|
||||||
|
<MudSelectItem Value="@RootParentValue">@T("Hauptmenue", "Root menu")</MudSelectItem>
|
||||||
|
@foreach (var group in ParentOptions(context))
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@group.Key">@GroupLabel(group)</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudSwitch @bind-Value="context.IsVisible" Color="Color.Primary" />
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudText Typo="Typo.caption">@context.Key</MudText>
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private const string RootParentValue = "__root__";
|
||||||
|
private List<NavigationMenuItem> _items = [];
|
||||||
|
private NavigationMenuItem? _draggedItem;
|
||||||
|
private bool _loading = true;
|
||||||
|
|
||||||
|
private IEnumerable<NavigationMenuItem> OrderedItems => _items
|
||||||
|
.OrderBy(x => x.ParentKey ?? string.Empty)
|
||||||
|
.ThenBy(x => x.SortOrder)
|
||||||
|
.ThenBy(x => x.TitleDe);
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
_loading = true;
|
||||||
|
_items = await NavigationMenuService.GetItemsAsync();
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
NormalizeSortOrders();
|
||||||
|
await NavigationMenuService.SaveItemsAsync(_items);
|
||||||
|
Snackbar.Add(T("Menuestruktur gespeichert.", "Menu structure saved."), Severity.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ResetAsync()
|
||||||
|
{
|
||||||
|
await NavigationMenuService.ResetToDefaultsAsync();
|
||||||
|
await LoadAsync();
|
||||||
|
Snackbar.Add(T("Standard-Menuestruktur wiederhergestellt.", "Default menu structure restored."), Severity.Info);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<NavigationMenuItem> ParentOptions(NavigationMenuItem item)
|
||||||
|
=> _items
|
||||||
|
.Where(x => x.ItemType == NavigationMenuItemTypes.Group)
|
||||||
|
.Where(x => x.Key != item.Key)
|
||||||
|
.Where(x => !WouldCreateCycle(item.Key, x.Key))
|
||||||
|
.OrderBy(x => x.TitleDe);
|
||||||
|
|
||||||
|
private void ChangeParent(NavigationMenuItem item, string parentKey)
|
||||||
|
{
|
||||||
|
item.ParentKey = parentKey == RootParentValue ? null : parentKey;
|
||||||
|
item.SortOrder = NextSortOrder(item.ParentKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Move(NavigationMenuItem item, int direction)
|
||||||
|
{
|
||||||
|
var siblings = _items
|
||||||
|
.Where(x => string.Equals(x.ParentKey, item.ParentKey, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderBy(x => x.SortOrder)
|
||||||
|
.ThenBy(x => x.TitleDe)
|
||||||
|
.ToList();
|
||||||
|
var index = siblings.FindIndex(x => x.Key == item.Key);
|
||||||
|
var targetIndex = index + direction;
|
||||||
|
if (index < 0 || targetIndex < 0 || targetIndex >= siblings.Count)
|
||||||
|
return;
|
||||||
|
|
||||||
|
(siblings[index].SortOrder, siblings[targetIndex].SortOrder) = (siblings[targetIndex].SortOrder, siblings[index].SortOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartDrag(NavigationMenuItem item)
|
||||||
|
{
|
||||||
|
_draggedItem = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DropOnRoot()
|
||||||
|
{
|
||||||
|
if (_draggedItem is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_draggedItem.ParentKey = null;
|
||||||
|
_draggedItem.SortOrder = NextSortOrder(null);
|
||||||
|
_draggedItem = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DropOn(NavigationMenuItem target)
|
||||||
|
{
|
||||||
|
if (_draggedItem is null || _draggedItem.Key == target.Key)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (target.ItemType == NavigationMenuItemTypes.Group && !WouldCreateCycle(_draggedItem.Key, target.Key))
|
||||||
|
{
|
||||||
|
_draggedItem.ParentKey = target.Key;
|
||||||
|
_draggedItem.SortOrder = NextSortOrder(target.Key);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_draggedItem.ParentKey = target.ParentKey;
|
||||||
|
_draggedItem.SortOrder = target.SortOrder - 1;
|
||||||
|
NormalizeSortOrders();
|
||||||
|
}
|
||||||
|
|
||||||
|
_draggedItem = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool WouldCreateCycle(string itemKey, string candidateParentKey)
|
||||||
|
{
|
||||||
|
var current = _items.FirstOrDefault(x => x.Key == candidateParentKey);
|
||||||
|
while (current is not null)
|
||||||
|
{
|
||||||
|
if (current.Key == itemKey)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
current = string.IsNullOrWhiteSpace(current.ParentKey)
|
||||||
|
? null
|
||||||
|
: _items.FirstOrDefault(x => x.Key == current.ParentKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int NextSortOrder(string? parentKey)
|
||||||
|
=> _items
|
||||||
|
.Where(x => string.Equals(x.ParentKey, parentKey, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(x => x.SortOrder)
|
||||||
|
.DefaultIfEmpty(0)
|
||||||
|
.Max() + 10;
|
||||||
|
|
||||||
|
private void NormalizeSortOrders()
|
||||||
|
{
|
||||||
|
foreach (var group in _items.GroupBy(x => x.ParentKey ?? string.Empty))
|
||||||
|
{
|
||||||
|
var sortOrder = 10;
|
||||||
|
foreach (var item in group.OrderBy(x => x.SortOrder).ThenBy(x => x.TitleDe))
|
||||||
|
{
|
||||||
|
item.SortOrder = sortOrder;
|
||||||
|
sortOrder += 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeParent(string? parentKey)
|
||||||
|
=> string.IsNullOrWhiteSpace(parentKey) ? RootParentValue : parentKey;
|
||||||
|
|
||||||
|
private string GroupLabel(NavigationMenuItem item)
|
||||||
|
=> UiText.Text(item.TitleDe, item.TitleEn);
|
||||||
|
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
|
||||||
|
private string Indent(NavigationMenuItem item)
|
||||||
|
{
|
||||||
|
var depth = 0;
|
||||||
|
var current = item;
|
||||||
|
while (!string.IsNullOrWhiteSpace(current.ParentKey))
|
||||||
|
{
|
||||||
|
depth++;
|
||||||
|
var next = _items.FirstOrDefault(x => x.Key == current.ParentKey);
|
||||||
|
if (next is null || next.Key == current.Key)
|
||||||
|
break;
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new string(' ', depth * 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.menu-drop-root {
|
||||||
|
border: 1px dashed var(--mud-palette-lines-default);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--mud-palette-text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-drag-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-bottom: 1px solid var(--mud-palette-lines-default);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-drag-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-drag-title {
|
||||||
|
white-space: pre;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-drag-meta {
|
||||||
|
color: var(--mud-palette-text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,289 @@
|
|||||||
|
@page "/einkauf/verbindungen"
|
||||||
|
@using TrafagSalesExporter.Services
|
||||||
|
@inject IPurchasingDataSourcePageService DataSourceService
|
||||||
|
@inject TrafagSalesExporter.Services.IUiTextService UiText
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
<PageTitle>@T("Einkauf Datenquellen", "Purchasing data sources")</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-1">@T("Einkauf Datenquellen", "Purchasing data sources")</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="mb-4 purchasing-muted">
|
||||||
|
@T("Grafische SAP/OData-Anbindung fuer das Einkaufsdashboard, analog zur Finance-Quellenpflege.",
|
||||||
|
"Graphical SAP/OData connection for the purchasing dashboard, following the finance source configuration pattern.")
|
||||||
|
</MudText>
|
||||||
|
|
||||||
|
@if (_loading)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Indeterminate="true" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudGrid Spacing="2" Class="mb-4">
|
||||||
|
<MudItem xs="12" md="7">
|
||||||
|
<MudPaper Class="pa-3" Outlined="true">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-3">@T("Verbindung", "Connection")</MudText>
|
||||||
|
<MudGrid Spacing="2">
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudTextField Value="_state.Site.TSC" Label="TSC" ReadOnly="true" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudTextField Value="_state.Site.SourceSystem" Label="@T("Quellsystem", "Source system")" ReadOnly="true" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudTextField @bind-Value="_state.Site.SapServiceUrl"
|
||||||
|
Label="SAP Service URL Override"
|
||||||
|
HelperText="@T("Leer lassen = zentrale SAP OData URL aus Settings verwenden.", "Leave empty = use central SAP OData URL from settings.")" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudTextField @bind-Value="_state.Site.UsernameOverride"
|
||||||
|
Label="Username Override"
|
||||||
|
HelperText="@T("Leer lassen = zentraler SAP User.", "Leave empty = central SAP user.")" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudTextField @bind-Value="_state.Site.PasswordOverride"
|
||||||
|
Label="Password Override"
|
||||||
|
InputType="InputType.Password"
|
||||||
|
HelperText="@T("Leer lassen = zentrales SAP Passwort.", "Leave empty = central SAP password.")" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudCheckBox @bind-Value="_state.Site.IsActive"
|
||||||
|
Label="@T("Einkaufsquelle fuer Import aktivieren", "Activate purchasing source for import")" />
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
<MudStack Row="true" Spacing="2" Class="mt-3">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Save" OnClick="SaveAsync" Disabled="_busy">
|
||||||
|
@T("Speichern", "Save")
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.NetworkCheck" OnClick="TestConnectionAsync" Disabled="_busy">
|
||||||
|
@T("Verbindung testen", "Test connection")
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Restore" OnClick="ResetDefaultsAsync" Disabled="_busy">
|
||||||
|
@T("Defaults wiederherstellen", "Restore defaults")
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="5">
|
||||||
|
<MudPaper Class="pa-3" Outlined="true">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Aktuelle Basis", "Current basis")</MudText>
|
||||||
|
<div class="purchasing-source-row">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.CloudQueue" Size="Size.Small" />
|
||||||
|
<span>@T("Zentrale URL", "Central URL"): <code>@Display(_state.SourceSystem?.CentralServiceUrl)</code></span>
|
||||||
|
</div>
|
||||||
|
<div class="purchasing-source-row">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.TableChart" Size="Size.Small" />
|
||||||
|
<span>@T("Quellen", "Sources"): @_state.Sources.Count(x => x.IsActive)</span>
|
||||||
|
</div>
|
||||||
|
<div class="purchasing-source-row">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.AccountTree" Size="Size.Small" />
|
||||||
|
<span>@T("Joins", "Joins"): @_state.Joins.Count(x => x.IsActive)</span>
|
||||||
|
</div>
|
||||||
|
<div class="purchasing-source-row">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Schema" Size="Size.Small" />
|
||||||
|
<span>@T("Mappings", "Mappings"): @_state.Mappings.Count(x => x.IsActive)</span>
|
||||||
|
</div>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<MudTabs Elevation="1" Rounded="false" PanelClass="pt-4">
|
||||||
|
<MudTabPanel Text="@T("Quellen", "Sources")" Icon="@Icons.Material.Filled.Hub">
|
||||||
|
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
|
||||||
|
<MudText Typo="Typo.h6">@T("OData Entity Sets", "OData entity sets")</MudText>
|
||||||
|
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddSource">@T("Quelle", "Source")</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
<MudTable Items="_state.Sources" Dense="true" Hover="true" Striped="true">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>Alias</MudTh>
|
||||||
|
<MudTh>Entity Set</MudTh>
|
||||||
|
<MudTh>@T("Primaer", "Primary")</MudTh>
|
||||||
|
<MudTh>@T("Aktiv", "Active")</MudTh>
|
||||||
|
<MudTh></MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd><MudTextField @bind-Value="context.Alias" Dense="true" /></MudTd>
|
||||||
|
<MudTd><MudTextField @bind-Value="context.EntitySet" Dense="true" /></MudTd>
|
||||||
|
<MudTd><MudCheckBox @bind-Value="context.IsPrimary" Dense="true" /></MudTd>
|
||||||
|
<MudTd><MudCheckBox @bind-Value="context.IsActive" Dense="true" /></MudTd>
|
||||||
|
<MudTd><MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="() => RemoveSource(context)" /></MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudTabPanel>
|
||||||
|
|
||||||
|
<MudTabPanel Text="@T("Join-Fluss", "Join flow")" Icon="@Icons.Material.Filled.AccountTree">
|
||||||
|
<MudGrid Spacing="2" Class="mb-3">
|
||||||
|
@foreach (var join in _state.Joins.Where(x => x.IsActive))
|
||||||
|
{
|
||||||
|
<MudItem xs="12" sm="6" lg="3">
|
||||||
|
<MudPaper Class="pa-3 purchasing-flow" Outlined="true">
|
||||||
|
<MudText Typo="Typo.subtitle2">@join.LeftAlias -> @join.RightAlias</MudText>
|
||||||
|
<MudText Typo="Typo.caption">@join.LeftKeys = @join.RightKeys</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
}
|
||||||
|
</MudGrid>
|
||||||
|
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
|
||||||
|
<MudText Typo="Typo.h6">@T("Joins", "Joins")</MudText>
|
||||||
|
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddJoin">@T("Join", "Join")</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
<MudTable Items="_state.Joins" Dense="true" Hover="true" Striped="true">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Links", "Left")</MudTh>
|
||||||
|
<MudTh>Left Keys</MudTh>
|
||||||
|
<MudTh>@T("Rechts", "Right")</MudTh>
|
||||||
|
<MudTh>Right Keys</MudTh>
|
||||||
|
<MudTh>@T("Typ", "Type")</MudTh>
|
||||||
|
<MudTh>@T("Aktiv", "Active")</MudTh>
|
||||||
|
<MudTh></MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd><MudTextField @bind-Value="context.LeftAlias" Dense="true" /></MudTd>
|
||||||
|
<MudTd><MudTextField @bind-Value="context.LeftKeys" Dense="true" /></MudTd>
|
||||||
|
<MudTd><MudTextField @bind-Value="context.RightAlias" Dense="true" /></MudTd>
|
||||||
|
<MudTd><MudTextField @bind-Value="context.RightKeys" Dense="true" /></MudTd>
|
||||||
|
<MudTd><MudTextField @bind-Value="context.JoinType" Dense="true" /></MudTd>
|
||||||
|
<MudTd><MudCheckBox @bind-Value="context.IsActive" Dense="true" /></MudTd>
|
||||||
|
<MudTd><MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="() => RemoveJoin(context)" /></MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudTabPanel>
|
||||||
|
|
||||||
|
<MudTabPanel Text="@T("Mapping", "Mapping")" Icon="@Icons.Material.Filled.Schema">
|
||||||
|
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
|
||||||
|
<MudText Typo="Typo.h6">@T("Zielfelder", "Target fields")</MudText>
|
||||||
|
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddMapping">@T("Mapping", "Mapping")</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
<MudTable Items="_state.Mappings" Dense="true" Hover="true" Striped="true">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Ziel", "Target")</MudTh>
|
||||||
|
<MudTh>@T("Quelle", "Source")</MudTh>
|
||||||
|
<MudTh>@T("Pflicht", "Required")</MudTh>
|
||||||
|
<MudTh>@T("Aktiv", "Active")</MudTh>
|
||||||
|
<MudTh></MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd><MudTextField @bind-Value="context.TargetField" Dense="true" /></MudTd>
|
||||||
|
<MudTd><MudTextField @bind-Value="context.SourceExpression" Dense="true" /></MudTd>
|
||||||
|
<MudTd><MudCheckBox @bind-Value="context.IsRequired" Dense="true" /></MudTd>
|
||||||
|
<MudTd><MudCheckBox @bind-Value="context.IsActive" Dense="true" /></MudTd>
|
||||||
|
<MudTd><MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="() => RemoveMapping(context)" /></MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudTabPanel>
|
||||||
|
</MudTabs>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private PurchasingDataSourceState _state = new();
|
||||||
|
private bool _loading = true;
|
||||||
|
private bool _busy;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_state = await DataSourceService.LoadAsync();
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
await RunAsync(async () =>
|
||||||
|
{
|
||||||
|
_state = await DataSourceService.SaveAsync(_state);
|
||||||
|
Snackbar.Add(T("Einkaufsdatenquellen gespeichert.", "Purchasing data sources saved."), Severity.Success);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ResetDefaultsAsync()
|
||||||
|
{
|
||||||
|
await RunAsync(async () =>
|
||||||
|
{
|
||||||
|
_state = await DataSourceService.ResetDefaultsAsync();
|
||||||
|
Snackbar.Add(T("Einkaufsdatenquellen auf Defaults gesetzt.", "Purchasing data sources restored to defaults."), Severity.Info);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TestConnectionAsync()
|
||||||
|
{
|
||||||
|
await RunAsync(async () =>
|
||||||
|
{
|
||||||
|
var result = await DataSourceService.TestConnectionAsync(_state);
|
||||||
|
Snackbar.Add(TranslateConnectionMessage(result.Message), result.Success ? Severity.Success : result.Warning ? Severity.Warning : Severity.Error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunAsync(Func<Task> action)
|
||||||
|
{
|
||||||
|
if (_busy)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_busy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await action();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add(ex.Message, Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddSource()
|
||||||
|
=> _state.Sources.Add(new SapSourceDefinition { Alias = "NEW", EntitySet = "NewEntitySet", IsActive = true, SortOrder = (_state.Sources.Count + 1) * 10 });
|
||||||
|
|
||||||
|
private void AddJoin()
|
||||||
|
=> _state.Joins.Add(new SapJoinDefinition { LeftAlias = "EKKO", RightAlias = "NEW", LeftKeys = "Key", RightKeys = "Key", JoinType = "Left", IsActive = true, SortOrder = (_state.Joins.Count + 1) * 10 });
|
||||||
|
|
||||||
|
private void AddMapping()
|
||||||
|
=> _state.Mappings.Add(new SapFieldMapping { TargetField = "NewField", SourceExpression = "Alias.Field", IsActive = true, SortOrder = (_state.Mappings.Count + 1) * 10 });
|
||||||
|
|
||||||
|
private void RemoveSource(SapSourceDefinition source) => _state.Sources.Remove(source);
|
||||||
|
private void RemoveJoin(SapJoinDefinition join) => _state.Joins.Remove(join);
|
||||||
|
private void RemoveMapping(SapFieldMapping mapping) => _state.Mappings.Remove(mapping);
|
||||||
|
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "-" : value;
|
||||||
|
|
||||||
|
private string TranslateConnectionMessage(string message)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(message))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
if (message.Contains("Keine SAP Service URL gepflegt", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return T("Keine SAP Service URL gepflegt.", "No SAP service URL maintained.");
|
||||||
|
if (message.Contains("Keine SAP Gateway Zugangsdaten gepflegt", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return T("Keine SAP Gateway Zugangsdaten gepflegt.", "No SAP Gateway credentials maintained.");
|
||||||
|
if (message.Contains("SAP OData Verbindung erfolgreich", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return T("SAP OData Verbindung erfolgreich.", "SAP OData connection successful.");
|
||||||
|
if (message.StartsWith("SAP OData Verbindung fehlgeschlagen", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return message.Replace("SAP OData Verbindung fehlgeschlagen", T("SAP OData Verbindung fehlgeschlagen", "SAP OData connection failed"), StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.purchasing-muted {
|
||||||
|
color: var(--mud-palette-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchasing-flow {
|
||||||
|
min-height: 86px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchasing-source-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 26px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 0;
|
||||||
|
border-bottom: 1px solid var(--mud-palette-lines-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchasing-source-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
@inject TrafagSalesExporter.Services.IUiTextService UiText
|
||||||
|
@using TrafagSalesExporter.Models
|
||||||
|
|
||||||
|
<MudPaper Class="pa-3 purchasing-section-shell" Outlined="true">
|
||||||
|
<div class="purchasing-section-head">
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.h6">@T(TitleDe, TitleEn)</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="purchasing-section-muted">@T(DescriptionDe, DescriptionEn)</MudText>
|
||||||
|
</div>
|
||||||
|
<MudChip T="string" Color="@ResolveSectionColor()" Variant="Variant.Outlined" Size="Size.Small">@ResolveSectionStatus()</MudChip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MudGrid Spacing="2" Class="mb-3">
|
||||||
|
@foreach (var kpi in Kpis)
|
||||||
|
{
|
||||||
|
<MudItem xs="12" sm="6" lg="3">
|
||||||
|
<MudPaper Class="pa-3 purchasing-section-kpi" Outlined="true">
|
||||||
|
<MudText Typo="Typo.caption" Class="purchasing-section-muted">@T(kpi.LabelDe, kpi.LabelEn)</MudText>
|
||||||
|
<MudText Typo="Typo.h6">@kpi.Value</MudText>
|
||||||
|
<MudText Typo="Typo.caption">@T(kpi.DetailDe, kpi.DetailEn)</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
}
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<MudGrid Spacing="2" Class="mb-3">
|
||||||
|
<MudItem xs="12" lg="7">
|
||||||
|
<MudPaper Class="pa-3 purchasing-section-panel" Outlined="true">
|
||||||
|
<div class="purchasing-section-panel-head">
|
||||||
|
<MudText Typo="Typo.subtitle1">@T(ChartTitleDe, ChartTitleEn)</MudText>
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.StackedBarChart" Color="Color.Info" Size="Size.Small" />
|
||||||
|
</div>
|
||||||
|
<div class="purchasing-bars">
|
||||||
|
@foreach (var item in ChartRows)
|
||||||
|
{
|
||||||
|
<div class="purchasing-bar-row">
|
||||||
|
<div class="purchasing-bar-label">@item.Label</div>
|
||||||
|
<div class="purchasing-bar-track">
|
||||||
|
<div class="purchasing-bar-fill" style="@($"width:{BuildWidth(item.Percent)}%; background:{item.Color}")"></div>
|
||||||
|
</div>
|
||||||
|
<div class="purchasing-bar-value">@item.Value</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" lg="5">
|
||||||
|
<MudPaper Class="pa-3 purchasing-section-panel" Outlined="true">
|
||||||
|
<div class="purchasing-section-panel-head">
|
||||||
|
<MudText Typo="Typo.subtitle1">@T("Datenstatus", "Data status")</MudText>
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Route" Color="Color.Info" Size="Size.Small" />
|
||||||
|
</div>
|
||||||
|
@foreach (var status in StatusRows)
|
||||||
|
{
|
||||||
|
<div class="purchasing-status-row">
|
||||||
|
<MudIcon Icon="@status.Icon" Color="@status.Color" Size="Size.Small" />
|
||||||
|
<span>@T(status.LabelDe, status.LabelEn)</span>
|
||||||
|
<strong>@status.Value</strong>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<MudTable Items="@DetailRows" Dense="true" Hover="true" Striped="true">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Bereich", "Area")</MudTh>
|
||||||
|
<MudTh>@T("Wert", "Value")</MudTh>
|
||||||
|
<MudTh>@T("Dimension", "Dimension")</MudTh>
|
||||||
|
<MudTh>@T("Quelle", "Source")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@T(context.LabelDe, context.LabelEn)</MudTd>
|
||||||
|
<MudTd><strong>@context.Value</strong></MudTd>
|
||||||
|
<MudTd>@context.Dimension</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="@ResolveSourceColor(context.Source)">
|
||||||
|
@context.Source
|
||||||
|
</MudChip>
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, EditorRequired] public string TitleDe { get; set; } = string.Empty;
|
||||||
|
[Parameter, EditorRequired] public string TitleEn { get; set; } = string.Empty;
|
||||||
|
[Parameter, EditorRequired] public string DescriptionDe { get; set; } = string.Empty;
|
||||||
|
[Parameter, EditorRequired] public string DescriptionEn { get; set; } = string.Empty;
|
||||||
|
[Parameter, EditorRequired] public string ChartTitleDe { get; set; } = string.Empty;
|
||||||
|
[Parameter, EditorRequired] public string ChartTitleEn { get; set; } = string.Empty;
|
||||||
|
[Parameter, EditorRequired] public IReadOnlyList<PurchasingSectionKpi> Kpis { get; set; } = [];
|
||||||
|
[Parameter, EditorRequired] public IReadOnlyList<PurchasingSectionChartRow> ChartRows { get; set; } = [];
|
||||||
|
[Parameter, EditorRequired] public IReadOnlyList<PurchasingSectionStatusRow> StatusRows { get; set; } = [];
|
||||||
|
[Parameter, EditorRequired] public IReadOnlyList<PurchasingSectionDetailRow> DetailRows { get; set; } = [];
|
||||||
|
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
private static string BuildWidth(double percent)
|
||||||
|
=> Math.Clamp(percent, 3d, 100d).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
private static Color ResolveSourceColor(string source)
|
||||||
|
=> source.Equals("SAP live", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? Color.Success
|
||||||
|
: source.Equals("Wartet auf SAP", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? Color.Warning
|
||||||
|
: Color.Primary;
|
||||||
|
|
||||||
|
private Color ResolveSectionColor()
|
||||||
|
=> DetailRows.Any(row => row.Source.Equals("SAP live", StringComparison.OrdinalIgnoreCase))
|
||||||
|
? Color.Success
|
||||||
|
: DetailRows.Any(row => row.Source.Equals("Simulation", StringComparison.OrdinalIgnoreCase))
|
||||||
|
? Color.Info
|
||||||
|
: Color.Warning;
|
||||||
|
|
||||||
|
private string ResolveSectionStatus()
|
||||||
|
=> DetailRows.Any(row => row.Source.Equals("SAP live", StringComparison.OrdinalIgnoreCase))
|
||||||
|
? T("Live + Analyse", "Live + analysis")
|
||||||
|
: DetailRows.Any(row => row.Source.Equals("Simulation", StringComparison.OrdinalIgnoreCase))
|
||||||
|
? T("Simulation aktiv", "Simulation active")
|
||||||
|
: T("Wartet auf SAP", "Waiting for SAP");
|
||||||
|
}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.purchasing-section-muted {
|
||||||
|
color: var(--mud-palette-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchasing-section-shell {
|
||||||
|
border-radius: 8px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(21,101,192,.045), rgba(21,101,192,0) 210px),
|
||||||
|
var(--mud-palette-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchasing-section-head,
|
||||||
|
.purchasing-section-panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchasing-section-head {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchasing-section-panel-head {
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchasing-section-kpi {
|
||||||
|
min-height: 104px;
|
||||||
|
border-top: 4px solid var(--mud-palette-primary);
|
||||||
|
background: var(--mud-palette-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchasing-section-panel {
|
||||||
|
min-height: 240px;
|
||||||
|
background: rgba(255,255,255,.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchasing-bars {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchasing-bar-row,
|
||||||
|
.purchasing-status-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(120px, 1fr) 2fr auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchasing-status-row {
|
||||||
|
grid-template-columns: 28px minmax(120px, 1fr) auto;
|
||||||
|
padding: 9px 0;
|
||||||
|
border-bottom: 1px solid var(--mud-palette-lines-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchasing-status-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchasing-bar-track {
|
||||||
|
height: 24px;
|
||||||
|
background: rgba(0,0,0,.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchasing-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 26px;
|
||||||
|
box-shadow: inset 0 -8px 16px rgba(0,0,0,.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchasing-bar-value {
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchasing-bar-label {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@@media (max-width: 760px) {
|
||||||
|
.purchasing-bar-row,
|
||||||
|
.purchasing-status-row,
|
||||||
|
.purchasing-section-head {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchasing-section-head,
|
||||||
|
.purchasing-section-panel-head {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
@page "/settings"
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
|
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
|
||||||
|
@using TrafagSalesExporter.Models
|
||||||
|
@using TrafagSalesExporter.Services
|
||||||
|
@inject ISettingsPageService SettingsPageActions
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IUiTextService UiText
|
||||||
|
|
||||||
|
<PageTitle>@T("Einstellungen", "Settings")</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-4">@T("Einstellungen", "Settings")</MudText>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h5" Class="mb-2">@T("Konfiguration Import/Export", "Import/export configuration")</MudText>
|
||||||
|
<MudPaper Class="pa-4 mb-6" Elevation="1">
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudCheckBox @bind-Value="_includeSecretsInExport" Label="@T("Mit Secrets exportieren", "Export with secrets")" />
|
||||||
|
<MudText Typo="Typo.caption">
|
||||||
|
@T("Wenn deaktiviert, bleiben Passwoerter und Secrets beim Export leer. Beim Import ohne Secrets werden bestehende Secrets auf dem Zielsystem beibehalten.",
|
||||||
|
"If disabled, passwords and secrets remain empty during export. When importing without secrets, existing secrets on the target system are kept.")
|
||||||
|
</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudStack Row Spacing="2">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="ExportConfiguration"
|
||||||
|
StartIcon="@Icons.Material.Filled.Download" Disabled="_exportingConfig">
|
||||||
|
@(_exportingConfig ? T("Exportiere...", "Exporting...") : T("Konfiguration exportieren", "Export configuration"))
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Warning" HtmlTag="label"
|
||||||
|
StartIcon="@Icons.Material.Filled.UploadFile" Disabled="_importingConfig">
|
||||||
|
@(_importingConfig ? T("Importiere...", "Importing...") : T("Konfiguration importieren", "Import configuration"))
|
||||||
|
<InputFile OnChange="ImportConfiguration" accept=".json,application/json" style="display:none" />
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@* SharePoint Config *@
|
||||||
|
<MudText Typo="Typo.h5" Class="mb-2">@T("SharePoint Konfiguration", "SharePoint configuration")</MudText>
|
||||||
|
<MudPaper Class="pa-4 mb-6" Elevation="1">
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudTextField @bind-Value="_spConfig.SiteUrl" Label="Site URL" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudTextField @bind-Value="_spConfig.ExportFolder" Label="Export Folder" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudTextField @bind-Value="_spConfig.CentralExportFolder"
|
||||||
|
Label="Central Export Folder"
|
||||||
|
HelperText="@T("Optional. Wenn leer, wird weiterhin Export Folder/Alle verwendet.", "Optional. If empty, Export Folder/All is still used.")" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudTextField @bind-Value="_spConfig.TenantId" Label="Tenant ID" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudTextField @bind-Value="_spConfig.ClientId" Label="Client ID" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudTextField @bind-Value="_spConfig.ClientSecret" Label="Client Secret" InputType="InputType.Password" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudStack Row Spacing="2">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSharePoint"
|
||||||
|
StartIcon="@Icons.Material.Filled.Save">
|
||||||
|
@T("Speichern", "Save")
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="TestSharePoint"
|
||||||
|
StartIcon="@Icons.Material.Filled.NetworkCheck" Disabled="_testingSp">
|
||||||
|
@if (_testingSp)
|
||||||
|
{
|
||||||
|
<MudProgressCircular Size="Size.Small" Indeterminate Class="mr-2" />
|
||||||
|
@T("Teste...", "Testing...")
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@T("SharePoint Verbindung testen", "Test SharePoint connection")
|
||||||
|
}
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudItem>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_sharePointTestPreview))
|
||||||
|
{
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mt-3">
|
||||||
|
<div><b>@T("Testvorschau", "Test preview")</b></div>
|
||||||
|
<div style="white-space: pre-wrap">@_sharePointTestPreview</div>
|
||||||
|
</MudAlert>
|
||||||
|
</MudItem>
|
||||||
|
}
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h5" Class="mb-2">@T("Quellsysteme", "Source systems")</MudText>
|
||||||
|
<MudPaper Class="pa-4 mb-6" Elevation="1">
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined">
|
||||||
|
@T("Diese Zugangsdaten werden pro Quellsystem als Standard verwendet. Ein Standort kann sie bei Bedarf mit eigenen Overrides ueberschreiben.",
|
||||||
|
"These credentials are used as defaults per source system. A site can override them if needed.")
|
||||||
|
</MudAlert>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="AddSourceSystem"
|
||||||
|
StartIcon="@Icons.Material.Filled.Add" Class="mb-3">
|
||||||
|
@T("Quellsystem hinzufuegen", "Add source system")
|
||||||
|
</MudButton>
|
||||||
|
<MudTable Items="_sourceSystems" Dense Hover Striped Breakpoint="Breakpoint.Md">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>Code</MudTh>
|
||||||
|
<MudTh>@T("Name", "Name")</MudTh>
|
||||||
|
<MudTh>@T("Anschlussart", "Connection type")</MudTh>
|
||||||
|
<MudTh>@T("Zentrale URL", "Central URL")</MudTh>
|
||||||
|
<MudTh>User</MudTh>
|
||||||
|
<MudTh>@T("Aktiv", "Active")</MudTh>
|
||||||
|
<MudTh>@T("Test", "Test")</MudTh>
|
||||||
|
<MudTh></MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Code</MudTd>
|
||||||
|
<MudTd>@context.DisplayName</MudTd>
|
||||||
|
<MudTd>@GetConnectionKindLabel(context.ConnectionKind)</MudTd>
|
||||||
|
<MudTd>@GetServiceUrlSummary(context)</MudTd>
|
||||||
|
<MudTd>@GetUsernameSummary(context)</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@if (context.IsActive)
|
||||||
|
{
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Default" Size="Size.Small" />
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@if (!UsesManualImport(context))
|
||||||
|
{
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Info" Size="Size.Small"
|
||||||
|
OnClick='@(() => TestCentralCredentials(context.Code))'
|
||||||
|
Disabled='@_testingSystems.Contains(context.Code)'>
|
||||||
|
@(_testingSystems.Contains(context.Code) ? T("Teste...", "Testing...") : T("Testen", "Test"))
|
||||||
|
</MudButton>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Edit" Color="Color.Primary" Size="Size.Small"
|
||||||
|
OnClick="() => EditSourceSystem(context)" />
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small"
|
||||||
|
OnClick="() => RemoveSourceSystem(context)" />
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSourceSystems"
|
||||||
|
StartIcon="@Icons.Material.Filled.Save">
|
||||||
|
@T("Quellsysteme speichern", "Save source systems")
|
||||||
|
</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudDialog @bind-Visible="_sourceSystemDialogVisible" Options="_sourceSystemDialogOptions">
|
||||||
|
<TitleContent>
|
||||||
|
<MudText Typo="Typo.h6">@(_editingSourceSystem.Id == 0 ? T("Quellsystem hinzufuegen", "Add source system") : T("Quellsystem bearbeiten", "Edit source system"))</MudText>
|
||||||
|
</TitleContent>
|
||||||
|
<DialogContent>
|
||||||
|
<MudTextField @bind-Value="_editingSourceSystem.Code" Label="Code" Required />
|
||||||
|
<MudTextField @bind-Value="_editingSourceSystem.DisplayName" Label="@T("Name", "Name")" Required />
|
||||||
|
<MudSelect T="string" @bind-Value="_editingSourceSystem.ConnectionKind" Label="@T("Anschlussart", "Connection type")" Required>
|
||||||
|
@foreach (var kind in SourceSystemConnectionKinds.All)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@kind">@GetConnectionKindLabel(kind)</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
@if (UsesSapGateway(_editingSourceSystem))
|
||||||
|
{
|
||||||
|
<MudTextField @bind-Value="_editingSourceSystem.CentralServiceUrl" Label="Zentrale SAP Service URL"
|
||||||
|
HelperText="@T("Zentrale Standard-URL fuer SAP Gateway. Ein Standort darf sie nur bei Bedarf ueberschreiben.", "Central default URL for SAP Gateway. A site may override it only if needed.")" />
|
||||||
|
}
|
||||||
|
<MudTextField @bind-Value="_editingSourceSystem.CentralUsername" Label="@T("Zentraler Username", "Central username")" />
|
||||||
|
<MudTextField @bind-Value="_editingSourceSystem.CentralPassword" Label="@T("Zentrales Passwort", "Central password")" InputType="InputType.Password" />
|
||||||
|
<MudCheckBox @bind-Value="_editingSourceSystem.IsActive" Label="@T("Aktiv", "Active")" />
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="CloseSourceSystemDialog">@T("Abbrechen", "Cancel")</MudButton>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSourceSystemEdit">@T("Uebernehmen", "Apply")</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h5" Class="mb-2">@T("Wechselkurse", "Exchange rates")</MudText>
|
||||||
|
<MudPaper Class="pa-4 mb-6" Elevation="1">
|
||||||
|
<MudText Typo="Typo.body2" Class="mb-3">
|
||||||
|
@((MarkupString)T("Diese Kurstabelle wird von der Transformation <b>ConvertCurrency</b> verwendet. Gleiche Waehrung rechnet automatisch mit Faktor 1.",
|
||||||
|
"This rate table is used by the <b>ConvertCurrency</b> transformation. Same-currency conversion automatically uses factor 1."))
|
||||||
|
</MudText>
|
||||||
|
<MudStack Row Spacing="2" Class="mb-3">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="AddExchangeRate"
|
||||||
|
StartIcon="@Icons.Material.Filled.Add">
|
||||||
|
@T("Kurs hinzufuegen", "Add rate")
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="RefreshEcbRates"
|
||||||
|
StartIcon="@Icons.Material.Filled.Refresh" Disabled="_refreshingExchangeRates">
|
||||||
|
@(_refreshingExchangeRates ? T("Aktualisiere ECB-Kurse...", "Refreshing ECB rates...") : T("Refresh Kurse", "Refresh rates"))
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveExchangeRates"
|
||||||
|
StartIcon="@Icons.Material.Filled.Save">
|
||||||
|
@T("Kurse speichern", "Save rates")
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
<MudTable Items="_exchangeRates" Hover="true" Breakpoint="Breakpoint.Md">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Von", "From")</MudTh>
|
||||||
|
<MudTh>@T("Nach", "To")</MudTh>
|
||||||
|
<MudTh>@T("Kurs", "Rate")</MudTh>
|
||||||
|
<MudTh>@T("Gueltig ab", "Valid from")</MudTh>
|
||||||
|
<MudTh>@T("Gueltig bis", "Valid to")</MudTh>
|
||||||
|
<MudTh>@T("Notiz", "Note")</MudTh>
|
||||||
|
<MudTh>@T("Aktiv", "Active")</MudTh>
|
||||||
|
<MudTh></MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>
|
||||||
|
<MudTextField @bind-Value="context.FromCurrency" Immediate="true" />
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudTextField @bind-Value="context.ToCurrency" Immediate="true" />
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudNumericField T="decimal" @bind-Value="context.Rate" Immediate="true" />
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudDatePicker Date="context.ValidFrom"
|
||||||
|
DateChanged="@(value => context.ValidFrom = value ?? context.ValidFrom)"
|
||||||
|
Editable="true" />
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudDatePicker Date="context.ValidTo"
|
||||||
|
DateChanged="@(value => context.ValidTo = value)"
|
||||||
|
Editable="true"
|
||||||
|
Clearable="true" />
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudTextField @bind-Value="context.Notes" Immediate="true" />
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudCheckBox @bind-Value="context.IsActive" />
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="@(() => RemoveExchangeRate(context))" />
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@* Export Settings *@
|
||||||
|
<MudText Typo="Typo.h5" Class="mb-2">@T("Export Einstellungen", "Export settings")</MudText>
|
||||||
|
<MudPaper Class="pa-4 mb-6" Elevation="1">
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudTextField @bind-Value="_exportSettings.DateFilter" Label="@T("Datum-Filter (ab)", "Date filter from")"
|
||||||
|
HelperText="@T("Format: yyyy-MM-dd", "Format: yyyy-MM-dd")" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2">
|
||||||
|
<MudNumericField @bind-Value="_exportSettings.TimerHour" Label="@T("Timer Stunde", "Timer hour")" Min="0" Max="23" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2">
|
||||||
|
<MudNumericField @bind-Value="_exportSettings.TimerMinute" Label="@T("Timer Minute", "Timer minute")" Min="0" Max="59" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudSwitch @bind-Value="_exportSettings.TimerEnabled" Label="@T("Timer aktiviert", "Timer enabled")" Color="Color.Primary" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudSelect T="string" @bind-Value="_exportSettings.ExchangeRateDateField"
|
||||||
|
Label="@T("Wechselkurse anwenden auf", "Apply exchange rates to")"
|
||||||
|
HelperText="@T("Datumsfeld fuer Kursgueltigkeit in Management-Analysen.", "Date field for rate validity in management analyses.")">
|
||||||
|
<MudSelectItem Value="@ExchangeRateDateFields.PostingDate">@T("PostingDate / Buchungsdatum", "PostingDate / posting date")</MudSelectItem>
|
||||||
|
<MudSelectItem Value="@ExchangeRateDateFields.InvoiceDate">@T("InvoiceDate / Rechnungsdatum", "InvoiceDate / invoice date")</MudSelectItem>
|
||||||
|
<MudSelectItem Value="@ExchangeRateDateFields.ExtractionDate">@T("ExtractionDate / Extraktionsdatum", "ExtractionDate / extraction date")</MudSelectItem>
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudSwitch @bind-Value="_exportSettings.DebugLoggingEnabled" Label="@T("Debug Live-Logging", "Debug live logging")" Color="Color.Warning" />
|
||||||
|
<MudText Typo="Typo.caption">
|
||||||
|
@T("Schreibt zusaetzliche technische Fortschrittsmeldungen fuer HANA- und SAP-Lesevorgaenge ins Dashboard und in die Logs.",
|
||||||
|
"Writes additional technical progress messages for HANA and SAP reads to the dashboard and logs.")
|
||||||
|
</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudTextField @bind-Value="_exportSettings.LocalSiteExportFolder" Label="@T("Lokaler Standardpfad Standort-Dateien", "Local default path for site files")"
|
||||||
|
HelperText="@T("Wenn leer, wird ./output unter dem Programmverzeichnis verwendet.", "If empty, ./output under the application directory is used.")" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudTextField @bind-Value="_exportSettings.LocalConsolidatedExportFolder" Label="@T("Lokaler Pfad Zentrale Datei", "Local path for central file")"
|
||||||
|
HelperText="@T("Optional. Wenn leer, wird der Standardpfad der Standort-Dateien verwendet.", "Optional. If empty, the default path for site files is used.")" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<div class="audit-csv-settings">
|
||||||
|
<div class="audit-csv-header">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.RuleFolder" Color="Color.Info" Size="Size.Medium" />
|
||||||
|
<div>
|
||||||
|
<MudText Typo="Typo.h6">@T("Audit-CSV / nachvollziehbarer Datenfluss", "Audit CSV / traceable data flow")</MudText>
|
||||||
|
<MudText Typo="Typo.body2">
|
||||||
|
@T("Fuer Finance und Wirtschaftspruefung: lesbare Standort-CSV nach Mapping und Konvertierung, optional als Quelle fuer zentrale Auswertungen.",
|
||||||
|
"For finance and auditors: readable site CSV after mapping and conversion, optionally as the source for central analyses.")
|
||||||
|
</MudText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MudGrid Spacing="2">
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudSwitch @bind-Value="_exportSettings.AuditCsvEnabled" Label="@T("Audit-CSV je Standort schreiben", "Write audit CSV per site")" Color="Color.Primary" />
|
||||||
|
<MudText Typo="Typo.caption">
|
||||||
|
@T("Schreibt beim Laenderexport je Standort eine Sales_ProcessedMergeInput_*.csv mit den transformierten Daten.",
|
||||||
|
"Writes one Sales_ProcessedMergeInput_*.csv per site during country export with the transformed data.")
|
||||||
|
</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudSwitch @bind-Value="_exportSettings.UseAuditCsvAsCentralSource" Label="@T("Zentrale Auswertung aus Audit-CSV", "Central analysis from audit CSV")" Color="Color.Warning" />
|
||||||
|
<MudText Typo="Typo.caption">
|
||||||
|
@T("Dashboard, zentrale Excel-Datei und Finance-Auswertungen lesen die neuesten Standort-CSV-Dateien statt CentralSalesRecords.",
|
||||||
|
"Dashboard, central Excel file and finance analyses read the latest site CSV files instead of CentralSalesRecords.")
|
||||||
|
</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Filled">
|
||||||
|
@((MarkupString)T("Audit-CSV wird immer im gleichen Ordner wie die lokalen Standort-Dateien abgelegt. Der Pfad wird oben bei <b>Lokaler Standardpfad Standort-Dateien</b> gesetzt.",
|
||||||
|
"Audit CSV is always stored in the same folder as the local site files. The path is set above under <b>Local default path for site files</b>."))
|
||||||
|
</MudAlert>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</div>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveExportSettings"
|
||||||
|
StartIcon="@Icons.Material.Filled.Save">
|
||||||
|
@T("Speichern", "Save")
|
||||||
|
</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@* Filename Preview *@
|
||||||
|
<MudText Typo="Typo.h5" Class="mb-2">@T("Dateiname Vorschau", "Filename preview")</MudText>
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.body1">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.InsertDriveFile" Size="Size.Small" Class="mr-1" />
|
||||||
|
Sales_{"{TSC}"}_{DateTime.Now:yyyy-MM-dd}.xlsx
|
||||||
|
</MudText>
|
||||||
|
<MudText Typo="Typo.caption" Class="mt-1">
|
||||||
|
@T("Beispiel:", "Example:") Sales_TRFR_@(DateTime.Now.ToString("yyyy-MM-dd")).xlsx
|
||||||
|
/ Sales_ProcessedMergeInput_TRFR_@(DateTime.Now.ToString("yyyy-MM-dd")).csv
|
||||||
|
</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.audit-csv-settings {
|
||||||
|
background: #e8f3ff;
|
||||||
|
border: 1px solid #90caf9;
|
||||||
|
border-left: 6px solid #1976d2;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-csv-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private SharePointConfig _spConfig = new();
|
||||||
|
private ExportSettings _exportSettings = new();
|
||||||
|
private List<SourceSystemDefinition> _sourceSystems = [];
|
||||||
|
private SourceSystemDefinition _editingSourceSystem = new();
|
||||||
|
private bool _testingSp;
|
||||||
|
private bool _includeSecretsInExport;
|
||||||
|
private bool _exportingConfig;
|
||||||
|
private bool _importingConfig;
|
||||||
|
private bool _refreshingExchangeRates;
|
||||||
|
private string _sharePointTestPreview = string.Empty;
|
||||||
|
private List<CurrencyExchangeRate> _exchangeRates = [];
|
||||||
|
private readonly HashSet<string> _testingSystems = [];
|
||||||
|
private bool _sourceSystemDialogVisible;
|
||||||
|
private readonly DialogOptions _sourceSystemDialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
var state = await SettingsPageActions.LoadAsync();
|
||||||
|
_spConfig = state.SharePointConfig;
|
||||||
|
_exportSettings = state.ExportSettings;
|
||||||
|
_sourceSystems = state.SourceSystems;
|
||||||
|
_exchangeRates = state.ExchangeRates;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveSharePoint()
|
||||||
|
{
|
||||||
|
await SettingsPageActions.SaveSharePointAsync(_spConfig);
|
||||||
|
Snackbar.Add(T("SharePoint Konfiguration gespeichert", "SharePoint configuration saved"), Severity.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TestSharePoint()
|
||||||
|
{
|
||||||
|
_testingSp = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_sharePointTestPreview = await SettingsPageActions.BuildSharePointTestPreviewAsync(_spConfig);
|
||||||
|
Snackbar.Add(T("SharePoint Verbindung erfolgreich!", "SharePoint connection successful!"), Severity.Success);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"{T("Verbindung fehlgeschlagen", "Connection failed")}: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_testingSp = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveExportSettings()
|
||||||
|
{
|
||||||
|
await SettingsPageActions.SaveExportSettingsAsync(_exportSettings);
|
||||||
|
Snackbar.Add(T("Export Einstellungen gespeichert", "Export settings saved"), Severity.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddSourceSystem()
|
||||||
|
{
|
||||||
|
_editingSourceSystem = new SourceSystemDefinition
|
||||||
|
{
|
||||||
|
Code = string.Empty,
|
||||||
|
DisplayName = string.Empty,
|
||||||
|
ConnectionKind = SourceSystemConnectionKinds.Hana,
|
||||||
|
IsActive = true
|
||||||
|
};
|
||||||
|
_sourceSystemDialogVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EditSourceSystem(SourceSystemDefinition definition)
|
||||||
|
{
|
||||||
|
_editingSourceSystem = new SourceSystemDefinition
|
||||||
|
{
|
||||||
|
Id = definition.Id,
|
||||||
|
Code = definition.Code,
|
||||||
|
DisplayName = definition.DisplayName,
|
||||||
|
ConnectionKind = definition.ConnectionKind,
|
||||||
|
IsActive = definition.IsActive,
|
||||||
|
CentralServiceUrl = definition.CentralServiceUrl,
|
||||||
|
CentralUsername = definition.CentralUsername,
|
||||||
|
CentralPassword = definition.CentralPassword
|
||||||
|
};
|
||||||
|
_sourceSystemDialogVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveSourceSystemEdit()
|
||||||
|
{
|
||||||
|
_editingSourceSystem.Code = NormalizeSourceSystemCode(_editingSourceSystem.Code);
|
||||||
|
_editingSourceSystem.DisplayName = NormalizeConfigValue(_editingSourceSystem.DisplayName);
|
||||||
|
_editingSourceSystem.ConnectionKind = NormalizeConnectionKind(_editingSourceSystem.ConnectionKind);
|
||||||
|
_editingSourceSystem.CentralServiceUrl = NormalizeConfigValue(_editingSourceSystem.CentralServiceUrl);
|
||||||
|
_editingSourceSystem.CentralUsername = NormalizeConfigValue(_editingSourceSystem.CentralUsername);
|
||||||
|
_editingSourceSystem.CentralPassword = _editingSourceSystem.CentralPassword ?? string.Empty;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_editingSourceSystem.Code) || string.IsNullOrWhiteSpace(_editingSourceSystem.DisplayName))
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Code und Name fuer das Quellsystem sind Pflicht.", "Code and name are required for the source system."), Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_sourceSystems.Any(x => x.Id != _editingSourceSystem.Id && x.Code == _editingSourceSystem.Code))
|
||||||
|
{
|
||||||
|
Snackbar.Add($"{T("Quellsystem-Code doppelt vorhanden", "Duplicate source-system code")}: {_editingSourceSystem.Code}", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_editingSourceSystem.Id == 0)
|
||||||
|
{
|
||||||
|
_sourceSystems.Add(_editingSourceSystem);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var existing = _sourceSystems.FirstOrDefault(x => x.Id == _editingSourceSystem.Id);
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
existing.Code = _editingSourceSystem.Code;
|
||||||
|
existing.DisplayName = _editingSourceSystem.DisplayName;
|
||||||
|
existing.ConnectionKind = _editingSourceSystem.ConnectionKind;
|
||||||
|
existing.IsActive = _editingSourceSystem.IsActive;
|
||||||
|
existing.CentralServiceUrl = _editingSourceSystem.CentralServiceUrl;
|
||||||
|
existing.CentralUsername = _editingSourceSystem.CentralUsername;
|
||||||
|
existing.CentralPassword = _editingSourceSystem.CentralPassword;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_sourceSystems = _sourceSystems.OrderBy(x => x.Code).ToList();
|
||||||
|
_sourceSystemDialogVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseSourceSystemDialog()
|
||||||
|
{
|
||||||
|
_sourceSystemDialogVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveSourceSystem(SourceSystemDefinition definition)
|
||||||
|
{
|
||||||
|
_sourceSystems.Remove(definition);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveSourceSystems()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_sourceSystems = await SettingsPageActions.SaveSourceSystemsAsync(_sourceSystems);
|
||||||
|
Snackbar.Add(T("Quellsysteme gespeichert", "Source systems saved"), Severity.Success);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add(ex.Message, Severity.Warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddExchangeRate()
|
||||||
|
{
|
||||||
|
_exchangeRates.Add(new CurrencyExchangeRate
|
||||||
|
{
|
||||||
|
FromCurrency = "USD",
|
||||||
|
ToCurrency = "EUR",
|
||||||
|
Rate = 1m,
|
||||||
|
ValidFrom = DateTime.Today,
|
||||||
|
IsActive = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveExchangeRate(CurrencyExchangeRate rate)
|
||||||
|
{
|
||||||
|
_exchangeRates.Remove(rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveExchangeRates()
|
||||||
|
{
|
||||||
|
_exchangeRates = await SettingsPageActions.SaveExchangeRatesAsync(_exchangeRates);
|
||||||
|
Snackbar.Add(T("Wechselkurse gespeichert", "Exchange rates saved"), Severity.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshEcbRates()
|
||||||
|
{
|
||||||
|
if (_refreshingExchangeRates)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_refreshingExchangeRates = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await SettingsPageActions.RefreshEcbRatesAsync();
|
||||||
|
_exchangeRates = result.ExchangeRates;
|
||||||
|
Snackbar.Add($"{T("ECB-Kurse aktualisiert", "ECB rates refreshed")}: {result.ImportedCount} {T("Kurse vom", "rates from")} {result.RateDate:yyyy-MM-dd}.", Severity.Success);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"{T("ECB-Kursimport fehlgeschlagen", "ECB rate import failed")}: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_refreshingExchangeRates = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExportConfiguration()
|
||||||
|
{
|
||||||
|
if (_exportingConfig)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_exportingConfig = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = await SettingsPageActions.ExportConfigurationAsync(_includeSecretsInExport);
|
||||||
|
var suffix = _includeSecretsInExport ? "with-secrets" : "without-secrets";
|
||||||
|
var fileName = $"trafag-config-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{suffix}.json";
|
||||||
|
await JS.InvokeVoidAsync("trafagDownload.saveTextFile", fileName, json, "application/json;charset=utf-8");
|
||||||
|
Snackbar.Add(T("Konfiguration exportiert", "Configuration exported"), Severity.Success);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"{T("Export fehlgeschlagen", "Export failed")}: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_exportingConfig = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ImportConfiguration(InputFileChangeEventArgs args)
|
||||||
|
{
|
||||||
|
if (_importingConfig)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_importingConfig = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var file = args.File;
|
||||||
|
await using var stream = file.OpenReadStream(5 * 1024 * 1024);
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
var json = await reader.ReadToEndAsync();
|
||||||
|
var state = await SettingsPageActions.ImportConfigurationAsync(json);
|
||||||
|
_spConfig = state.SharePointConfig;
|
||||||
|
_exportSettings = state.ExportSettings;
|
||||||
|
_sourceSystems = state.SourceSystems;
|
||||||
|
_exchangeRates = state.ExchangeRates;
|
||||||
|
Snackbar.Add(T("Konfiguration importiert", "Configuration imported"), Severity.Success);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"{T("Import fehlgeschlagen", "Import failed")}: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_importingConfig = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TestCentralCredentials(string sourceSystem)
|
||||||
|
{
|
||||||
|
var definition = _sourceSystems.FirstOrDefault(x => string.Equals(x.Code, sourceSystem, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (definition is null)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"{T("Quellsystem nicht gefunden", "Source system not found")}: {sourceSystem}", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_testingSystems.Add(sourceSystem))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await SettingsPageActions.TestCentralCredentialsAsync(definition);
|
||||||
|
Snackbar.Add(result.Message, result.Success ? Severity.Success : result.Warning ? Severity.Warning : Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_testingSystems.Remove(sourceSystem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeSourceSystemCode(string? code) => Services.SettingsPageService.NormalizeSourceSystemCode(code);
|
||||||
|
|
||||||
|
private static string NormalizeConnectionKind(string? connectionKind) => Services.SettingsPageService.NormalizeConnectionKind(connectionKind);
|
||||||
|
|
||||||
|
private static string GetConnectionKindLabel(string connectionKind) => connectionKind switch
|
||||||
|
{
|
||||||
|
SourceSystemConnectionKinds.Hana => "HANA",
|
||||||
|
SourceSystemConnectionKinds.SapGateway => "SAP Gateway",
|
||||||
|
SourceSystemConnectionKinds.ManualExcel => "Manual Excel",
|
||||||
|
_ => connectionKind
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool UsesManualImport(SourceSystemDefinition definition)
|
||||||
|
=> string.Equals(definition.ConnectionKind, SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static bool UsesSapGateway(SourceSystemDefinition definition)
|
||||||
|
=> string.Equals(definition.ConnectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static string GetServiceUrlSummary(SourceSystemDefinition definition)
|
||||||
|
=> string.IsNullOrWhiteSpace(definition.CentralServiceUrl) ? "-" : definition.CentralServiceUrl;
|
||||||
|
|
||||||
|
private static string GetUsernameSummary(SourceSystemDefinition definition)
|
||||||
|
=> string.IsNullOrWhiteSpace(definition.CentralUsername) ? "-" : definition.CentralUsername;
|
||||||
|
|
||||||
|
private static string NormalizeConfigValue(string? value) => Services.SettingsPageService.NormalizeConfigValue(value);
|
||||||
|
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
@page "/source-viewer"
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
|
@using Microsoft.AspNetCore.Components
|
||||||
|
@using Microsoft.AspNetCore.WebUtilities
|
||||||
|
@inject IWebHostEnvironment Environment
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject TrafagSalesExporter.Services.IUiTextService UiText
|
||||||
|
|
||||||
|
<PageTitle>@T("Source Viewer", "Source Viewer")</PageTitle>
|
||||||
|
|
||||||
|
<MudStack Spacing="2">
|
||||||
|
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
|
||||||
|
<MudText Typo="Typo.h5">@T("Source Viewer", "Source Viewer")</MudText>
|
||||||
|
<MudButton Variant="Variant.Outlined" Href="/transformations">
|
||||||
|
@T("Zurueck zur Transformation", "Back to transformations")
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_requestedPath))
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2">
|
||||||
|
@T("Datei:", "File:")
|
||||||
|
<MudText Inline="true" Typo="Typo.body2"><code>@_requestedPath</code></MudText>
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_requestedType))
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2">
|
||||||
|
@T("Klasse:", "Class:")
|
||||||
|
<MudText Inline="true" Typo="Typo.body2"><code>@_requestedType</code></MudText>
|
||||||
|
@if (_highlightLineNumber is not null)
|
||||||
|
{
|
||||||
|
<span> @T("bei Zeile", "at line") @_highlightLineNumber</span>
|
||||||
|
}
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_error))
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined">@_error</MudAlert>
|
||||||
|
}
|
||||||
|
else if (string.IsNullOrWhiteSpace(_content))
|
||||||
|
{
|
||||||
|
<MudProgressCircular Indeterminate="true" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-4">
|
||||||
|
<div style="font-family: Consolas, monospace; font-size: 0.9rem;">
|
||||||
|
@foreach (var line in _lines)
|
||||||
|
{
|
||||||
|
<div id="@GetLineAnchor(line.Number)"
|
||||||
|
style="@GetLineStyle(line.Number)">
|
||||||
|
<span style="display:inline-block; width:4rem; color:#666;">@line.Number.ToString("0000")</span>
|
||||||
|
<span>@line.Text</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</MudPaper>
|
||||||
|
@if (_highlightLineNumber is not null)
|
||||||
|
{
|
||||||
|
<script>
|
||||||
|
location.hash = '@GetLineAnchor(_highlightLineNumber.Value)';
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string? _requestedPath;
|
||||||
|
private string? _requestedType;
|
||||||
|
private string? _content;
|
||||||
|
private string? _error;
|
||||||
|
private List<SourceLine> _lines = [];
|
||||||
|
private int? _highlightLineNumber;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
|
||||||
|
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||||
|
_requestedPath = query.TryGetValue("path", out var value) ? value.ToString() : null;
|
||||||
|
_requestedType = query.TryGetValue("type", out var typeValue) ? typeValue.ToString() : null;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_requestedPath))
|
||||||
|
{
|
||||||
|
_error = T("Kein Dateipfad angegeben.", "No file path provided.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_requestedPath.Contains("..", StringComparison.Ordinal) || Path.IsPathRooted(_requestedPath))
|
||||||
|
{
|
||||||
|
_error = T("Ungueltiger Dateipfad.", "Invalid file path.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullPath = Path.Combine(Environment.ContentRootPath, _requestedPath.Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
if (!File.Exists(fullPath))
|
||||||
|
{
|
||||||
|
_error = string.Format(T("Datei nicht gefunden: {0}", "File not found: {0}"), _requestedPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_content = File.ReadAllText(fullPath);
|
||||||
|
_lines = _content
|
||||||
|
.Replace("\r\n", "\n", StringComparison.Ordinal)
|
||||||
|
.Split('\n')
|
||||||
|
.Select((text, index) => new SourceLine(index + 1, text))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_requestedType))
|
||||||
|
{
|
||||||
|
_highlightLineNumber = _lines
|
||||||
|
.FirstOrDefault(x => x.Text.Contains($"class {_requestedType}", StringComparison.Ordinal) ||
|
||||||
|
x.Text.Contains($"sealed class {_requestedType}", StringComparison.Ordinal) ||
|
||||||
|
x.Text.Contains($"public class {_requestedType}", StringComparison.Ordinal) ||
|
||||||
|
x.Text.Contains($"public sealed class {_requestedType}", StringComparison.Ordinal))
|
||||||
|
?.Number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetLineAnchor(int lineNumber) => $"line-{lineNumber}";
|
||||||
|
|
||||||
|
private string GetLineStyle(int lineNumber)
|
||||||
|
{
|
||||||
|
var highlight = _highlightLineNumber == lineNumber;
|
||||||
|
return highlight
|
||||||
|
? "background-color:#fff3cd; white-space:pre-wrap;"
|
||||||
|
: "white-space:pre-wrap;";
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record SourceLine(int Number, string Text);
|
||||||
|
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user