Merge pull request #36 from metacube2/claude/fix-layout-centering-cdX7d

Claude/fix layout centering cd x7d
This commit is contained in:
2026-01-22 21:39:19 +01:00
committed by GitHub
19 changed files with 6354 additions and 175 deletions
+54
View File
@@ -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 {
+4
View File
@@ -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)
+155 -1
View File
@@ -26,7 +26,8 @@ class SettingsManager {
return [ return [
'viewer_display' => [ 'viewer_display' => [
'enabled' => true, 'enabled' => true,
'min_viewers' => 1 'min_viewers' => 1,
'update_interval' => 5 // Sekunden
], ],
'video_mode' => [ 'video_mode' => [
'play_in_player' => true, 'play_in_player' => true,
@@ -36,6 +37,52 @@ class SettingsManager {
'default_speed' => 1, 'default_speed' => 1,
'available_speeds' => [1, 10, 100] 'available_speeds' => [1, 10, 100]
], ],
// Punkt 2: UI-Anzeige Features
'ui_display' => [
'show_recommendation_banner' => true,
'show_qr_code' => true,
'show_social_media' => true,
'show_patrouille_suisse' => true
],
// Punkt 3: Zoom & Timelapse
'zoom_timelapse' => [
'show_zoom_controls' => true,
'max_zoom_level' => 4.0,
'timelapse_reverse_enabled' => true
],
// Punkt 5: Content Management
'content' => [
'guestbook_enabled' => true,
'gallery_enabled' => true,
'ai_events_enabled' => true,
'max_guestbook_entries' => 50
],
// Punkt 6: Technische Settings
'technical' => [
'viewer_update_interval' => 5, // Sekunden
'session_timeout' => 30 // Sekunden
],
// Punkt 7: Theme & Design
'theme' => [
'default_theme' => 'theme-legacy',
'show_theme_switcher' => false
],
// Punkt 8: SEO & Meta
'seo' => [
'custom_title' => '',
'meta_description' => '',
'meta_keywords' => ''
],
// Weather Widget
'weather' => [
'enabled' => true,
'api_key' => '',
'location' => 'Oberdürnten,CH',
'lat' => '47.2833',
'lon' => '8.7167',
'update_interval' => 5, // Minuten
'units' => 'metric' // metric (Celsius) oder imperial (Fahrenheit)
],
'last_updated' => null, 'last_updated' => null,
'updated_by' => null 'updated_by' => null
]; ];
@@ -123,4 +170,111 @@ class SettingsManager {
public function shouldAllowDownload() { public function shouldAllowDownload() {
return $this->get('video_mode.allow_download') === true; return $this->get('video_mode.allow_download') === true;
} }
// UI Display Helper
public function shouldShowRecommendationBanner() {
return $this->get('ui_display.show_recommendation_banner') === true;
}
public function shouldShowQRCode() {
return $this->get('ui_display.show_qr_code') === true;
}
public function shouldShowSocialMedia() {
return $this->get('ui_display.show_social_media') === true;
}
public function shouldShowPatrouillesuisse() {
return $this->get('ui_display.show_patrouille_suisse') === true;
}
// Content Management Helper
public function isGuestbookEnabled() {
return $this->get('content.guestbook_enabled') === true;
}
public function isGalleryEnabled() {
return $this->get('content.gallery_enabled') === true;
}
public function isAIEventsEnabled() {
return $this->get('content.ai_events_enabled') === true;
}
public function getMaxGuestbookEntries() {
return $this->get('content.max_guestbook_entries') ?? 50;
}
// Theme Helper
public function getDefaultTheme() {
return $this->get('theme.default_theme') ?? 'theme-legacy';
}
public function shouldShowThemeSwitcher() {
return $this->get('theme.show_theme_switcher') === true;
}
// Technical Helper
public function getViewerUpdateInterval() {
return $this->get('technical.viewer_update_interval') ?? 5;
}
public function getSessionTimeout() {
return $this->get('technical.session_timeout') ?? 30;
}
// Zoom & Timelapse Helper
public function shouldShowZoomControls() {
return $this->get('zoom_timelapse.show_zoom_controls') === true;
}
public function getMaxZoomLevel() {
return $this->get('zoom_timelapse.max_zoom_level') ?? 4.0;
}
public function isTimelapseReverseEnabled() {
return $this->get('zoom_timelapse.timelapse_reverse_enabled') === true;
}
// SEO Helper
public function getCustomTitle() {
$title = $this->get('seo.custom_title');
return !empty($title) ? $title : null;
}
public function getMetaDescription() {
return $this->get('seo.meta_description') ?? '';
}
public function getMetaKeywords() {
return $this->get('seo.meta_keywords') ?? '';
}
// Weather Helper
public function isWeatherEnabled() {
return $this->get('weather.enabled') === true;
}
public function getWeatherApiKey() {
return $this->get('weather.api_key') ?? '';
}
public function getWeatherLocation() {
return $this->get('weather.location') ?? 'Oberdürnten,CH';
}
public function getWeatherCoords() {
return [
'lat' => $this->get('weather.lat') ?? '47.2833',
'lon' => $this->get('weather.lon') ?? '8.7167'
];
}
public function getWeatherUpdateInterval() {
return $this->get('weather.update_interval') ?? 5;
}
public function getWeatherUnits() {
return $this->get('weather.units') ?? 'metric';
}
} }
+160
View File
@@ -0,0 +1,160 @@
<?php
/**
* WeatherManager - Holt und cached Wetterdaten von OpenWeatherMap
*/
class WeatherManager {
private $settingsManager;
private $cacheFile = 'weather_cache.json';
private $cacheTime = 300; // 5 Minuten in Sekunden
public function __construct($settingsManager) {
$this->settingsManager = $settingsManager;
}
/**
* Holt aktuelle Wetterdaten (cached)
*/
public function getCurrentWeather() {
// Prüfe ob Weather aktiviert ist
if (!$this->settingsManager->isWeatherEnabled()) {
return null;
}
// Prüfe API Key
$apiKey = $this->settingsManager->getWeatherApiKey();
if (empty($apiKey)) {
return ['error' => 'API Key fehlt'];
}
// Prüfe Cache
$cached = $this->getCache();
if ($cached !== null) {
return $cached;
}
// Hole frische Daten von API
$coords = $this->settingsManager->getWeatherCoords();
$units = $this->settingsManager->getWeatherUnits();
$url = "https://api.openweathermap.org/data/2.5/weather?lat={$coords['lat']}&lon={$coords['lon']}&units={$units}&appid={$apiKey}&lang=de";
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200 || !$response) {
return ['error' => 'API Fehler'];
}
$data = json_decode($response, true);
if (!$data || !isset($data['main'])) {
return ['error' => 'Ungültige API Antwort'];
}
// Formatiere Daten
$weather = [
'temp' => round($data['main']['temp'], 1),
'feels_like' => round($data['main']['feels_like'], 1),
'humidity' => $data['main']['humidity'],
'pressure' => $data['main']['pressure'],
'wind_speed' => round($data['wind']['speed'] * 3.6, 1), // m/s -> km/h
'wind_deg' => $data['wind']['deg'] ?? 0,
'wind_direction' => $this->getWindDirection($data['wind']['deg'] ?? 0),
'clouds' => $data['clouds']['all'] ?? 0,
'description' => ucfirst($data['weather'][0]['description'] ?? 'Unbekannt'),
'icon' => $data['weather'][0]['icon'] ?? '01d',
'rain_1h' => $data['rain']['1h'] ?? 0,
'snow_1h' => $data['snow']['1h'] ?? 0,
'location' => $data['name'] ?? $this->settingsManager->getWeatherLocation(),
'timestamp' => time()
];
// Cache speichern
$this->saveCache($weather);
return $weather;
}
/**
* Wandelt Windrichtung (Grad) in Kompassrichtung um
*/
private function getWindDirection($deg) {
$directions = ['N', 'NNO', 'NO', 'ONO', 'O', 'OSO', 'SO', 'SSO', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
$index = round($deg / 22.5) % 16;
return $directions[$index];
}
/**
* Holt Daten aus Cache (wenn noch gültig)
*/
private function getCache() {
if (!file_exists($this->cacheFile)) {
return null;
}
$content = file_get_contents($this->cacheFile);
$data = json_decode($content, true);
if (!$data || !isset($data['timestamp'])) {
return null;
}
// Update-Intervall aus Settings holen (in Minuten)
$updateInterval = $this->settingsManager->getWeatherUpdateInterval() * 60; // Minuten -> Sekunden
// Prüfe ob Cache noch gültig
if (time() - $data['timestamp'] < $updateInterval) {
return $data;
}
return null;
}
/**
* Speichert Daten im Cache
*/
private function saveCache($data) {
$json = json_encode($data, JSON_PRETTY_PRINT);
file_put_contents($this->cacheFile, $json, LOCK_EX);
}
/**
* Gibt Wetter-Icon-Emoji zurück
*/
public function getWeatherEmoji($iconCode) {
$map = [
'01d' => '☀️', '01n' => '🌙',
'02d' => '⛅', '02n' => '☁️',
'03d' => '☁️', '03n' => '☁️',
'04d' => '☁️', '04n' => '☁️',
'09d' => '🌧️', '09n' => '🌧️',
'10d' => '🌦️', '10n' => '🌧️',
'11d' => '⛈️', '11n' => '⛈️',
'13d' => '❄️', '13n' => '❄️',
'50d' => '🌫️', '50n' => '🌫️'
];
return $map[$iconCode] ?? '🌤️';
}
/**
* AJAX Handler für Wetter-Updates
*/
public function handleAjax() {
if ($_SERVER['REQUEST_METHOD'] !== 'GET') return;
if (!isset($_GET['weather_action'])) return;
header('Content-Type: application/json');
if ($_GET['weather_action'] === 'get') {
$weather = $this->getCurrentWeather();
echo json_encode(['success' => true, 'data' => $weather]);
exit;
}
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
// Clear PHP OPcache
if (function_exists('opcache_reset')) {
opcache_reset();
echo "OPcache cleared successfully!\n";
} else {
echo "OPcache not available\n";
}
// Clear realpath cache
clearstatcache(true);
echo "Realpath cache cleared!\n";
echo "\nNow reload the page with CTRL+F5 (hard refresh)\n";
?>
+1193 -88
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+91 -83
View File
@@ -1,7 +1,6 @@
/** /**
* Video Zoom & Pan Controller * Video Zoom & Pan Controller
* - Zoom für alle Video-Modi (Live, Timelapse, Tagesvideo) * Zoomt auf Wrapper-Layer statt direkt auf Video-Elemente
* - Pan-Funktion: Mit Maus den gezoomten Bereich verschieben
*/ */
(() => { (() => {
const config = window.zoomConfig || {}; const config = window.zoomConfig || {};
@@ -11,60 +10,75 @@
let panX = 0; let panX = 0;
let panY = 0; let panY = 0;
let isDragging = false; let isDragging = false;
let startX = 0; let lastX = 0;
let startY = 0; let lastY = 0;
const minZoom = Number(config.minZoom || 1); const minZoom = Number(config.minZoom || 1);
const maxZoom = Number(config.maxZoom || 4); const maxZoom = Number(config.maxZoom || 4);
const defaultZoom = Number(config.defaultZoom || 1);
const slider = document.getElementById('zoom-range'); const slider = document.getElementById('zoom-range');
const valueEl = document.getElementById('zoom-value'); const valueEl = document.getElementById('zoom-value');
// Finde das aktuell aktive Video-Element // Wrapper-IDs für jeden Modus
function getActiveTarget() { const wrapperIds = ['live-video-wrapper', 'timelapse-wrapper', 'daily-video-wrapper'];
const webcam = document.getElementById('webcam-player');
const timelapse = document.getElementById('timelapse-image');
const daily = document.getElementById('daily-video');
const timelapseViewer = document.getElementById('timelapse-viewer');
const dailyPlayer = document.getElementById('daily-video-player');
// Prüfe welches Element sichtbar ist // Finde den aktuell sichtbaren Wrapper
if (dailyPlayer && dailyPlayer.style.display !== 'none' && daily) { function getActiveWrapper() {
return daily; // Prüfe daily-video-player
const dailyPlayer = document.getElementById('daily-video-player');
if (dailyPlayer && dailyPlayer.style.display !== 'none') {
return document.getElementById('daily-video-wrapper');
} }
if (timelapseViewer && timelapseViewer.style.display !== 'none' && timelapse) {
return timelapse; // Prüfe timelapse-viewer
const timelapseViewer = document.getElementById('timelapse-viewer');
if (timelapseViewer && timelapseViewer.style.display !== 'none') {
return document.getElementById('timelapse-wrapper');
} }
if (webcam) {
return webcam; // Fallback: Live-Video
} return document.getElementById('live-video-wrapper');
return null;
} }
// Wende Zoom und Pan auf das aktive Element an // Wende Transform auf ALLE Wrapper an (damit beim Wechsel der Zoom erhalten bleibt)
function applyTransform() { function applyTransform() {
const target = getActiveTarget(); // Bei Zoom 1x: Kein Pan
if (!target) return;
// Bei Zoom 1x: Kein Pan erlaubt
if (currentZoom <= 1) { if (currentZoom <= 1) {
panX = 0; panX = 0;
panY = 0; panY = 0;
} }
// Begrenzen der Pan-Werte basierend auf Zoom // Pan begrenzen basierend auf Zoom
const maxPan = (currentZoom - 1) * 50; // Prozent const maxPan = (currentZoom - 1) * 50;
panX = Math.max(-maxPan, Math.min(maxPan, panX)); panX = Math.max(-maxPan, Math.min(maxPan, panX));
panY = Math.max(-maxPan, Math.min(maxPan, panY)); panY = Math.max(-maxPan, Math.min(maxPan, panY));
target.style.transform = `scale(${currentZoom}) translate(${panX}%, ${panY}%)`; // Transform auf alle Wrapper anwenden
target.style.transformOrigin = 'center center'; wrapperIds.forEach(id => {
target.style.transition = isDragging ? 'none' : 'transform 0.2s ease'; const wrapper = document.getElementById(id);
if (wrapper) {
wrapper.style.transform = `scale(${currentZoom}) translate(${panX}%, ${panY}%)`;
wrapper.style.transition = isDragging ? 'none' : 'transform 0.15s ease-out';
}
});
// Update UI // UI Update
if (valueEl) valueEl.textContent = `${currentZoom.toFixed(1)}x`; if (valueEl) valueEl.textContent = `${currentZoom.toFixed(1)}x`;
if (slider) slider.value = currentZoom; if (slider) slider.value = currentZoom;
// Cursor Update
updateCursor();
}
function updateCursor() {
const container = document.querySelector('.video-container');
if (container) {
if (currentZoom > 1) {
container.classList.add('zoomed');
} else {
container.classList.remove('zoomed');
}
}
} }
// Zoom setzen // Zoom setzen
@@ -91,57 +105,68 @@
const container = document.querySelector('.video-container'); const container = document.querySelector('.video-container');
if (!container) return; if (!container) return;
// Mousedown - Start dragging
container.addEventListener('mousedown', (e) => { container.addEventListener('mousedown', (e) => {
if (currentZoom <= 1) return; if (currentZoom <= 1) return;
// Ignoriere Klicks auf Controls
if (e.target.closest('.zoom-controls, button, a')) return;
isDragging = true; isDragging = true;
startX = e.clientX; lastX = e.clientX;
startY = e.clientY; lastY = e.clientY;
container.style.cursor = 'grabbing';
e.preventDefault(); e.preventDefault();
}); });
// Mousemove - Dragging
document.addEventListener('mousemove', (e) => { document.addEventListener('mousemove', (e) => {
if (!isDragging) return; if (!isDragging) return;
const dx = (e.clientX - startX) / 5; // Sensitivität anpassen const deltaX = e.clientX - lastX;
const dy = (e.clientY - startY) / 5; const deltaY = e.clientY - lastY;
panX += dx / currentZoom; // Sensitivität basierend auf Zoom
panY += dy / currentZoom; const sensitivity = 0.15 / currentZoom;
panX += deltaX * sensitivity;
panY += deltaY * sensitivity;
startX = e.clientX; lastX = e.clientX;
startY = e.clientY; lastY = e.clientY;
applyTransform(); applyTransform();
}); });
// Mouseup - Stop dragging
document.addEventListener('mouseup', () => { document.addEventListener('mouseup', () => {
if (isDragging) { isDragging = false;
isDragging = false; });
const container = document.querySelector('.video-container');
if (container) container.style.cursor = currentZoom > 1 ? 'grab' : 'default'; // Mouse leave
} document.addEventListener('mouseleave', () => {
isDragging = false;
}); });
// Touch Events für Mobile // Touch Events für Mobile
container.addEventListener('touchstart', (e) => { container.addEventListener('touchstart', (e) => {
if (currentZoom <= 1 || e.touches.length !== 1) return; if (currentZoom <= 1 || e.touches.length !== 1) return;
if (e.target.closest('.zoom-controls, button, a')) return;
isDragging = true; isDragging = true;
startX = e.touches[0].clientX; lastX = e.touches[0].clientX;
startY = e.touches[0].clientY; lastY = e.touches[0].clientY;
}, { passive: true }); }, { passive: true });
container.addEventListener('touchmove', (e) => { container.addEventListener('touchmove', (e) => {
if (!isDragging || e.touches.length !== 1) return; if (!isDragging || e.touches.length !== 1) return;
const dx = (e.touches[0].clientX - startX) / 5; const deltaX = e.touches[0].clientX - lastX;
const dy = (e.touches[0].clientY - startY) / 5; const deltaY = e.touches[0].clientY - lastY;
panX += dx / currentZoom; const sensitivity = 0.15 / currentZoom;
panY += dy / currentZoom; panX += deltaX * sensitivity;
panY += deltaY * sensitivity;
startX = e.touches[0].clientX; lastX = e.touches[0].clientX;
startY = e.touches[0].clientY; lastY = e.touches[0].clientY;
applyTransform(); applyTransform();
}, { passive: true }); }, { passive: true });
@@ -150,25 +175,28 @@
isDragging = false; isDragging = false;
}); });
// Cursor anpassen bei Zoom // Doppelklick zum Zurücksetzen
container.style.cursor = 'default'; container.addEventListener('dblclick', (e) => {
if (e.target.closest('.zoom-controls, button, a')) return;
resetZoom();
});
} }
// Slider Events // Slider Setup
function setupSlider() { function setupSlider() {
if (!slider) return; if (!slider) return;
slider.min = minZoom; slider.min = minZoom;
slider.max = maxZoom; slider.max = maxZoom;
slider.step = 0.5; slider.step = 0.5;
slider.value = defaultZoom; slider.value = 1;
slider.addEventListener('input', (e) => { slider.addEventListener('input', (e) => {
setZoom(Number(e.target.value)); setZoom(Number(e.target.value));
}); });
} }
// Globale Funktionen für Buttons // Globale Funktionen
window.adjustZoom = adjustZoom; window.adjustZoom = adjustZoom;
window.resetZoom = resetZoom; window.resetZoom = resetZoom;
window.setZoom = setZoom; window.setZoom = setZoom;
@@ -177,31 +205,11 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
setupSlider(); setupSlider();
setupPanEvents(); setupPanEvents();
currentZoom = defaultZoom;
// Warte kurz, damit Video-Elemente geladen sind // Initial State
setTimeout(() => { currentZoom = 1;
applyTransform(); applyTransform();
}, 500);
// Update Cursor bei Zoom-Änderung console.log('Video Zoom & Pan initialized');
const container = document.querySelector('.video-container');
if (container) {
const observer = new MutationObserver(() => {
container.style.cursor = currentZoom > 1 ? 'grab' : 'default';
});
}
});
// Bei Moduswechsel Pan zurücksetzen
window.addEventListener('click', (e) => {
if (e.target.id === 'timelapse-button' ||
e.target.closest('#timelapse-button') ||
e.target.id === 'dvp-back-live' ||
e.target.closest('.play-link')) {
panX = 0;
panY = 0;
setTimeout(applyTransform, 100);
}
}); });
})(); })();
+46 -2
View File
@@ -1,7 +1,8 @@
{ {
"viewer_display": { "viewer_display": {
"enabled": true, "enabled": true,
"min_viewers": 1 "min_viewers": 1,
"update_interval": 5
}, },
"video_mode": { "video_mode": {
"play_in_player": true, "play_in_player": true,
@@ -9,7 +10,50 @@
}, },
"timelapse": { "timelapse": {
"default_speed": 1, "default_speed": 1,
"available_speeds": [1, 10, 100] "available_speeds": [
1,
10,
100
]
},
"ui_display": {
"show_recommendation_banner": true,
"show_qr_code": true,
"show_social_media": true,
"show_patrouille_suisse": true
},
"zoom_timelapse": {
"show_zoom_controls": true,
"max_zoom_level": 4.0,
"timelapse_reverse_enabled": true
},
"content": {
"guestbook_enabled": true,
"gallery_enabled": true,
"ai_events_enabled": true,
"max_guestbook_entries": 50
},
"technical": {
"viewer_update_interval": 5,
"session_timeout": 30
},
"theme": {
"default_theme": "theme-legacy",
"show_theme_switcher": false
},
"seo": {
"custom_title": "",
"meta_description": "",
"meta_keywords": ""
},
"weather": {
"enabled": true,
"api_key": "",
"location": "Oberdürnten,CH",
"lat": "47.2833",
"lon": "8.7167",
"update_interval": 5,
"units": "metric"
}, },
"last_updated": null, "last_updated": null,
"updated_by": null "updated_by": null
+225
View File
@@ -0,0 +1,225 @@
Erstelle ein Word-Dokument: "Power BI Schulungshandbuch für HR" mit Schritt-für-Schritt-Anleitungen.
ZIELGRUPPE:
- 3-4 HR-Mitarbeiterinnen, Schweiz
- Excel-Kenntnisse: Basis + SVERWEIS
- Technikaffinität: 5-6/10
- Keine Power BI Vorkenntnisse
DATENQUELLEN DER TEILNEHMER:
- SAP HCM/HRM (alle Infotypen, besonders PA0001, PA0002, PA0008, PA2001)
- Rexx HR-System (Stellenplan, Pulsumfrage, MA-Zufriedenheit)
- Excel/CSV (Kununu-Score, Refline/Time-to-hire)
KPIs DIE ABGEBILDET WERDEN SOLLEN:
- Headcount/FTE (monatlich)
- Fluktuation (monatlich)
- Krankenquote gesamt + ohne Langzeitkrankheiten >30 Tage (Quartal)
- Überstunden (Quartal)
- Produktivstunden (wöchentlich)
- Ferientage/GLZ-Saldi (jährlich)
- Stellenplan Soll vs Ist (monatlich, aus Rexx)
- Lohnkosten (monatlich)
- Time to hire (Quartal)
- Kununu Score (monatlich)
- Pulsumfrage (Quartal, aus Rexx)
- MA-Zufriedenheitsumfrage (jährlich, aus Rexx)
ZIELGRUPPEN DER REPORTS:
- Geschäftsleitung
- Verwaltungsrat
- Finanzbuchhaltung
- Abteilungsleiter
STRUKTUR DES DOKUMENTS:
1. MODUL 1: GRUNDLAGEN & DATENIMPORT
1.1 Power BI Desktop installieren und starten
- Wo herunterladen, Installation, erster Start
1.2 Oberfläche kennenlernen
- Berichtsansicht, Datenansicht, Modellansicht erklären
- Wo findet man was (Menüband, Felder-Bereich, Visualisierungen)
1.3 Excel-Datei importieren
- Schritt-für-Schritt: Daten abrufen → Excel → Datei wählen → Navigator → Laden
- Häufige Probleme und Lösungen
1.4 CSV importieren
- Unterschiede zu Excel, Encoding-Probleme Schweiz (Umlaute)
1.5 SAP-Export importieren
- Typische SAP-Exportformate verarbeiten
- Spaltenüberschriften aus erster Zeile
2. MODUL 2: POWER QUERY EDITOR
2.1 Power Query öffnen
- Daten transformieren → Button finden
2.2 Erste Zeile als Header verwenden
- Schritt-für-Schritt mit Menüpfad
2.3 Datentypen ändern
- Datum, Zahl, Text erkennen und korrigieren
- Schweizer Datumsformat beachten
2.4 Spalten entfernen/behalten
- Nur relevante Spalten behalten
2.5 Zeilen filtern
- Beispiel: Nur aktive Mitarbeiter, nur bestimmter Zeitraum
2.6 Werte ersetzen
- null durch 0 ersetzen, Codes durch Klartext
2.7 Spalten teilen/zusammenführen
2.8 Berechnete Spalte hinzufügen
2.9 Schliessen und Laden
- Unterschied: Laden vs. Laden in
3. MODUL 3: DATENMODELL
3.1 Zur Modellansicht wechseln
3.2 Beziehungen verstehen
- 1:n, 1:1 erklären
- Warum Beziehungen wichtig sind
3.3 Beziehung erstellen
- Drag & Drop zwischen Tabellen
- Beziehung bearbeiten (Kardinalität, Kreuzfilterrichtung)
3.4 Datumstabelle erstellen
- Warum eigene Datumstabelle nötig
- DAX-Formel zum Erstellen:
Datum = ADDCOLUMNS(CALENDAR(DATE(2020,1,1), TODAY()), "Jahr", YEAR([Date]), "Monat", MONTH([Date]), "MonatName", FORMAT([Date],"MMMM"), "Quartal", "Q" & QUARTER([Date]), "KW", WEEKNUM([Date]))
- Als Datumstabelle markieren (Menüpfad)
3.5 PERNR als Schlüssel
- Personalnummer verbindet alle SAP-Tabellen
4. MODUL 4: DAX MEASURES
4.1 Was ist ein Measure vs. berechnete Spalte
4.2 Neues Measure erstellen
- Menüpfad: Modellierung → Neues Measure
4.3 Basis-Measures für HR:
Headcount:
Headcount = COUNTROWS(Mitarbeiter)
FTE:
FTE = SUMX(Mitarbeiter, Mitarbeiter[Beschäftigungsgrad]/100)
Krankheitstage:
Krankheitstage = SUM(Abwesenheiten[Kalendertage])
Sollarbeitstage:
Sollarbeitstage = [Headcount] * 21
Krankenquote:
Krankenquote = DIVIDE([Krankheitstage], [Sollarbeitstage], 0)
Krankenquote ohne Langzeit (>30 Tage):
Krankenquote_ohne_LZ =
VAR KrankheitstageKurz = CALCULATE([Krankheitstage], FILTER(Abwesenheiten, Abwesenheiten[Kalendertage] <= 30))
RETURN DIVIDE(KrankheitstageKurz, [Sollarbeitstage], 0)
Austritte:
Austritte = CALCULATE(COUNTROWS(Mitarbeiter), Mitarbeiter[Austritt] <> BLANK())
Durchschnittlicher Headcount:
Avg_Headcount = AVERAGEX(VALUES(Datum[Monat]), [Headcount])
Fluktuation:
Fluktuation = DIVIDE([Austritte], [Avg_Headcount], 0) * 100
4.4 Zeitintelligenz-Measures:
Vorjahreswert:
Headcount_VJ = CALCULATE([Headcount], SAMEPERIODLASTYEAR(Datum[Date]))
Vormonat:
Headcount_VM = CALCULATE([Headcount], PREVIOUSMONTH(Datum[Date]))
Year-to-Date:
Headcount_YTD = TOTALYTD([Headcount], Datum[Date])
Delta zum Vorjahr:
Delta_VJ = [Headcount] - [Headcount_VJ]
Delta Prozent:
Delta_VJ_Proz = DIVIDE([Delta_VJ], [Headcount_VJ], 0)
4.5 Measures formatieren
- Prozent, Dezimalstellen, Währung einstellen
5. MODUL 5: VISUALISIERUNGEN
5.1 Visualisierungstypen und wann verwenden:
- Karte/Card: Einzelne KPI-Zahl (Headcount, Krankenquote)
- Balkendiagramm: Vergleiche (Abteilungen, Monate)
- Liniendiagramm: Zeitverläufe (Headcount über 12 Monate)
- Ringdiagramm: Anteile (Absenzen nach Typ)
- Tachometer: Ziel vs Ist (Stellenplan-Erfüllung)
- Tabelle/Matrix: Details mit Drill-down
5.2 Erste Visualisierung erstellen
- Schritt-für-Schritt: Visualisierung wählen → Felder reinziehen
5.3 Visualisierung formatieren
- Titel, Farben, Schriftgrössen
5.4 Filter hinzufügen
- Visualfilter, Seitenfilter, Berichtsfilter
5.5 Slicer erstellen
- Zeitraum-Auswahl, Abteilungs-Auswahl
5.6 Bedingte Formatierung
- Rot/Grün je nach Wert (Ampel-Logik)
6. MODUL 6: DASHBOARD BAUEN
6.1 Dashboard-Layout planen
- F-Muster: Wichtigstes oben links
- Max 6-8 Visualisierungen pro Seite
6.2 Seite 1: Management-Übersicht erstellen
- KPI-Karten oben: Headcount, Krankenquote, Fluktuation, Stellenplan
- Trendlinie Headcount
- Absenzquote nach Typ
6.3 Seite 2: Detailanalyse erstellen
- Matrix mit Drill-down nach Abteilung
- Filter für Zeitraum und Kostenstelle
6.4 Interaktionen zwischen Visualisierungen
- Klick auf Balken filtert andere Visuals
- Interaktionen bearbeiten (Menüpfad)
6.5 Design-Tipps
- Konsistente Farben (Firmen-CI)
- Genügend Weissraum
- Beschriftungen lesbar
7. MODUL 7: VERÖFFENTLICHEN & TEILEN
7.1 Power BI Service (app.powerbi.com)
- Konto erstellen/anmelden
- Unterschied Desktop vs Service
7.2 Bericht veröffentlichen
- Menüpfad: Datei → Veröffentlichen → Arbeitsbereich wählen
7.3 Arbeitsbereich einrichten
7.4 Dashboard erstellen (aus Bericht)
- Visualisierung anheften
7.5 Bericht teilen
- Link teilen, Zugriff verwalten
7.6 Automatische Aktualisierung einrichten
- Geplante Aktualisierung (täglich, wöchentlich)
- Gateway für lokale Daten (IT einbeziehen)
7.7 Row-Level Security (RLS)
- Abteilungsleiter sehen nur eigene Daten
- Rolle erstellen, DAX-Filter: [Abteilung] = USERPRINCIPALNAME()
8. TROUBLESHOOTING
8.1 Häufige Fehler beim Import
- Encoding-Probleme (UTF-8)
- Falsches Dezimaltrennzeichen (Punkt vs Komma)
- Datum wird als Text erkannt
8.2 Häufige DAX-Fehler
- Zirkelbezug
- Division durch Null (DIVIDE verwenden)
- Falscher Filterkontext
8.3 Beziehungsprobleme
- Mehrdeutige Beziehungen
- Fehlende Beziehung
8.4 Performance-Probleme
- Zu viele Spalten importiert
- Berechnete Spalten vs Measures
9. ANHANG
9.1 DAX Cheat Sheet (alle HR-Formeln auf einer Seite)
9.2 Checkliste: Neuen Report erstellen
9.3 Glossar (Power Query, DAX, Measure, etc.)
FORMAT-ANWEISUNGEN:
- Jeder Schritt nummeriert
- Menüpfade in Format: Reiter → Gruppe → Button
- DAX-Formeln in Codeblock/Monospace
- Tipps und Warnungen hervorheben
- Screenshots beschreiben wo sinnvoll: [Screenshot: Beschreibung was zu sehen sein sollte]
- Sprache: Deutsch (Schweiz), Du-Form
@@ -0,0 +1,556 @@
<!DOCTYPE html>
<html lang="de-CH">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Power BI Schulungshandbuch für HR</title>
<style>
:root {
color-scheme: light;
--accent: #1f6feb;
--accent-soft: #e0f2fe;
--text: #0f172a;
--muted: #475569;
--bg: #f8fafc;
--card: #ffffff;
--border: #e2e8f0;
--warning: #f97316;
--success: #16a34a;
--code: #0b1020;
}
body {
margin: 0;
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
color: var(--text);
background: var(--bg);
line-height: 1.7;
}
header {
background: linear-gradient(120deg, #e0f2fe 0%, #eef2ff 100%);
padding: 40px 24px 24px;
border-bottom: 1px solid var(--border);
}
header h1 {
margin: 0 0 8px 0;
font-size: 2.2rem;
}
header p {
margin: 6px 0;
color: var(--muted);
}
main {
max-width: 1050px;
margin: 0 auto;
padding: 24px;
}
section {
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 26px;
margin-bottom: 22px;
box-shadow: 0 4px 18px rgba(15, 23, 42, 0.05);
}
h2 {
margin-top: 0;
color: #111827;
border-bottom: 2px solid var(--border);
padding-bottom: 6px;
}
h3 {
margin-bottom: 6px;
color: #1e293b;
}
h4 {
margin: 14px 0 6px;
color: #1f2937;
}
.badge {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 0.85rem;
background: var(--accent-soft);
color: #0369a1;
margin-left: 8px;
}
ul, ol {
margin: 8px 0 16px 24px;
}
.callout {
border-left: 4px solid var(--accent);
background: #eef2ff;
padding: 12px 16px;
border-radius: 8px;
margin: 12px 0;
color: #1e293b;
}
.warning {
border-left-color: var(--warning);
background: #fff7ed;
}
.success {
border-left-color: var(--success);
background: #ecfdf3;
}
pre {
background: var(--code);
color: #e2e8f0;
padding: 16px;
border-radius: 10px;
overflow-x: auto;
}
code {
font-family: "Consolas", "Courier New", monospace;
}
figure {
margin: 0;
padding: 0;
}
figcaption {
color: var(--muted);
font-size: 0.9rem;
margin-top: 8px;
}
.grid-two {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
}
.grid-three {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: 16px;
}
.kpi-list li {
margin-bottom: 4px;
}
.checklist li {
margin-bottom: 6px;
}
.small {
font-size: 0.92rem;
color: var(--muted);
}
.flow-box {
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px;
background: #f1f5f9;
}
</style>
</head>
<body>
<header>
<h1>Power BI Schulungshandbuch für HR</h1>
<p>Für 34 HR-Mitarbeiterinnen (Schweiz) mit wenig IT-Kenntnissen und Excel-Basis.</p>
<p class="small">Hinweis: Eine Word-Version ist nicht im Repo enthalten (Binary-Dateien werden beim PR-Erstellen nicht unterstützt).</p>
</header>
<main>
<section>
<h2>Überblick</h2>
<div class="grid-two">
<div>
<h3>Zielgruppe</h3>
<ul>
<li>34 HR-Mitarbeiterinnen (Schweiz)</li>
<li>Excel: Basis + SVERWEIS</li>
<li>Technikaffinität: 56/10</li>
<li>Keine Power BI Vorkenntnisse</li>
</ul>
</div>
<div>
<h3>Zielgruppen der Reports</h3>
<ul>
<li>Geschäftsleitung</li>
<li>Verwaltungsrat</li>
<li>Finanzbuchhaltung</li>
<li>Abteilungsleiter</li>
</ul>
</div>
</div>
<h3>Datenquellen</h3>
<ul>
<li>SAP HCM/HRM (Infotypen PA0001, PA0002, PA0008, PA2001)</li>
<li>Rexx HR-System (Stellenplan, Pulsumfrage, MA-Zufriedenheit)</li>
<li>Excel/CSV (Kununu-Score, Refline/Time-to-hire)</li>
</ul>
<h3>KPIs (mit Periodizität)</h3>
<ul class="kpi-list">
<li>Headcount/FTE (monatlich)</li>
<li>Fluktuation (monatlich)</li>
<li>Krankenquote gesamt + ohne Langzeitkrankheiten &gt;30 Tage (Quartal)</li>
<li>Überstunden (Quartal)</li>
<li>Produktivstunden (wöchentlich)</li>
<li>Ferientage/GLZ-Saldi (jährlich)</li>
<li>Stellenplan Soll vs Ist (monatlich, Rexx)</li>
<li>Lohnkosten (monatlich)</li>
<li>Time to hire (Quartal)</li>
<li>Kununu Score (monatlich)</li>
<li>Pulsumfrage (Quartal, Rexx)</li>
<li>MA-Zufriedenheitsumfrage (jährlich, Rexx)</li>
</ul>
<figure>
<svg width="100%" height="260" viewBox="0 0 980 260" role="img" aria-label="Datenfluss von Quellen zu Power BI und Reports">
<defs>
<linearGradient id="box" x1="0" x2="1">
<stop offset="0%" stop-color="#e0f2fe"/>
<stop offset="100%" stop-color="#eef2ff"/>
</linearGradient>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L9,3 L0,6 Z" fill="#64748b" />
</marker>
</defs>
<rect x="20" y="30" width="240" height="60" rx="12" fill="url(#box)" stroke="#94a3b8" />
<text x="140" y="65" text-anchor="middle" font-size="14" fill="#0f172a">SAP HCM/HRM</text>
<rect x="20" y="120" width="240" height="60" rx="12" fill="url(#box)" stroke="#94a3b8" />
<text x="140" y="155" text-anchor="middle" font-size="14" fill="#0f172a">Rexx HR-System</text>
<rect x="20" y="210" width="240" height="60" rx="12" fill="url(#box)" stroke="#94a3b8" />
<text x="140" y="245" text-anchor="middle" font-size="14" fill="#0f172a">Excel/CSV</text>
<rect x="350" y="100" width="260" height="80" rx="14" fill="#1f6feb" opacity="0.12" stroke="#1f6feb" />
<text x="480" y="145" text-anchor="middle" font-size="16" fill="#1f6feb">Power BI Desktop</text>
<rect x="700" y="70" width="260" height="120" rx="14" fill="#ecfeff" stroke="#0ea5e9" />
<text x="830" y="120" text-anchor="middle" font-size="14" fill="#0f172a">Berichte &amp; Dashboards</text>
<text x="830" y="145" text-anchor="middle" font-size="12" fill="#475569">GL · VR · Finanzen · Abteilungen</text>
<line x1="260" y1="60" x2="350" y2="120" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)" />
<line x1="260" y1="150" x2="350" y2="140" stroke="#64748b" stroke-width="2" />
<line x1="260" y1="240" x2="350" y2="160" stroke="#64748b" stroke-width="2" />
<line x1="610" y1="140" x2="700" y2="130" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)" />
</svg>
<figcaption>Grafik: Datenfluss von HR-Quellen in Power BI bis zu den Zielgruppen-Reports.</figcaption>
</figure>
</section>
<section>
<h2>Vorbereitung: Arbeitsordner &amp; Dateien <span class="badge">Start</span></h2>
<ol>
<li>Lege einen Ordner <strong>HR-Power-BI</strong> an.</li>
<li>Erstelle Unterordner: <code>01_Rohdaten</code>, <code>02_Transformiert</code>, <code>03_Berichte</code>.</li>
<li>Speichere Exporte aus SAP/Rexx/Excel immer in <code>01_Rohdaten</code>.</li>
<li>Benutze klare Dateinamen mit Datum, z. B. <code>SAP_PA0001_2025-01.csv</code>.</li>
</ol>
<div class="callout success">Ziel: Alle Teammitglieder finden Dateien sofort wieder und arbeiten mit den gleichen Daten.</div>
</section>
<section>
<h2>1. Grundlagen &amp; Datenimport <span class="badge">Modul 1</span></h2>
<h3>1.1 Installation &amp; erster Start</h3>
<ol>
<li>Gehe auf <strong>https://powerbi.microsoft.com/de-de/desktop/</strong> und lade Power BI Desktop herunter.</li>
<li>Installiere mit Standardoptionen (Weiter → Installieren → Fertigstellen).</li>
<li>Starte Power BI Desktop und wähle <strong>Leerer Bericht</strong>.</li>
<li>Speichere die Datei als <code>HR-Reporting.pbix</code> in <code>03_Berichte</code>.</li>
</ol>
<div class="callout">Tipp: Speichere früh und oft Power BI Desktop hat keine Auto-Speicherung.</div>
<h3>1.2 Oberfläche kennenlernen</h3>
<ol>
<li>Links: Berichtsansicht (Diagramme), Datenansicht (Tabellen), Modellansicht (Beziehungen).</li>
<li>Rechts: Visualisierungen (Diagramm-Typ), Felder (Spalten), Filter.</li>
<li>Oben: Menüband mit allen Funktionen.</li>
</ol>
<div class="callout">Merksatz: <strong>Felder</strong> sind die Daten, <strong>Visualisierungen</strong> sind die Diagramme.</div>
<h3>1.3 Excel importieren (Kununu, Refline)</h3>
<ol>
<li><strong>Start → Daten abrufen → Excel</strong>.</li>
<li>Datei auswählen → <strong>Öffnen</strong>.</li>
<li>Im Navigator das richtige Blatt wählen (z. B. <code>Kununu_Score</code>).</li>
<li>Klicke <strong>Laden</strong>.</li>
</ol>
<div class="callout warning">Warnung: Excel-Tabellen ohne Überschrift führen zu „Spalte1/Spalte2“. Nutze in Power Query „Erste Zeile als Überschrift“.</div>
<h3>1.4 CSV importieren (Time-to-hire)</h3>
<ol>
<li><strong>Start → Daten abrufen → Text/CSV</strong>.</li>
<li>Datei auswählen → <strong>Öffnen</strong>.</li>
<li>Prüfe <strong>Trennzeichen</strong> (meist Semikolon).</li>
<li>Setze <strong>Dateiursprung</strong> auf UTF-8.</li>
</ol>
<div class="callout">Tipp: Umlaute (ä, ö, ü) sind das beste Zeichen, ob die Kodierung stimmt.</div>
<h3>1.5 SAP-Exporte importieren</h3>
<ol>
<li>SAP-Export lokal speichern (z. B. PA0001, PA0002, PA0008, PA2001).</li>
<li>Jeden Infotyp als eigene Tabelle laden.</li>
<li>Tabellen sofort umbenennen: <code>Mitarbeiter_Org</code>, <code>Mitarbeiter_Personal</code>, <code>Mitarbeiter_Lohn</code>, <code>Absenzen</code>.</li>
</ol>
<div class="callout warning">Warnung: SAP-Daten enthalten oft führende Nullen bei Personalnummern (PERNR). Nicht löschen!</div>
</section>
<section>
<h2>2. Power Query Editor <span class="badge">Modul 2</span></h2>
<h3>2.1 Power Query öffnen</h3>
<ol>
<li><strong>Start → Daten transformieren</strong>.</li>
<li>Du siehst eine Vorschau-Tabelle pro Datenquelle.</li>
</ol>
<h3>2.2 Erste Zeile als Überschrift</h3>
<ol>
<li><strong>Transformieren → Erste Zeile als Überschriften</strong>.</li>
<li>Kontrolliere, ob Spaltennamen sinnvoll sind.</li>
</ol>
<h3>2.3 Datentypen richtig setzen</h3>
<ol>
<li>Datumsspalten: <strong>Datum</strong> auswählen.</li>
<li>Zahlen: <strong>Ganze Zahl</strong> oder <strong>Dezimalzahl</strong>.</li>
<li>Text: <strong>Text</strong>.</li>
</ol>
<div class="callout warning">Warnung: Schweizer Datumsformat (TT.MM.JJJJ) braucht oft „Datentyp mit Gebietsschema (Deutsch Schweiz)“.</div>
<h3>2.4 Spalten entfernen / behalten</h3>
<ol>
<li>Unnötige Spalten markieren → <strong>Spalten entfernen</strong>.</li>
<li>Wenn nur 68 Spalten relevant sind: <strong>Andere Spalten entfernen</strong>.</li>
</ol>
<h3>2.5 Zeilen filtern</h3>
<ol>
<li>Filterpfeil in der Spalte <strong>Status</strong>.</li>
<li>Nur aktive Mitarbeitende wählen.</li>
<li>Zeitraum (z. B. letztes Jahr) filtern.</li>
</ol>
<h3>2.6 Werte ersetzen</h3>
<ol>
<li><strong>Transformieren → Werte ersetzen</strong>.</li>
<li><code>null</code> durch <code>0</code> ersetzen.</li>
<li>Codes wie <code>A</code> in Klartext (<code>Aktiv</code>) umwandeln.</li>
</ol>
<h3>2.7 Spalten teilen / zusammenführen</h3>
<ol>
<li>Spalte auswählen → <strong>Spalte teilen</strong> (z. B. Vorname/Nachname).</li>
<li>Mehrere Spalten zusammenführen (z. B. Vorname + Nachname).</li>
</ol>
<h3>2.8 Berechnete Spalte</h3>
<ol>
<li><strong>Spalte hinzufügen → Benutzerdefinierte Spalte</strong>.</li>
<li>Beispiel: FTE = Beschäftigungsgrad / 100.</li>
</ol>
<h3>2.9 Schliessen &amp; Laden</h3>
<ol>
<li><strong>Start → Schliessen &amp; Laden</strong>.</li>
<li>„Laden in“ nutzen, wenn du nur eine Verbindung brauchst.</li>
</ol>
</section>
<section>
<h2>3. Datenmodell <span class="badge">Modul 3</span></h2>
<h3>3.1 Beziehungen verstehen</h3>
<div class="grid-three">
<div class="flow-box">
<strong>1:n Beziehung</strong>
<p class="small">Eine Personalnummer in der Mitarbeitertabelle kann viele Abwesenheitszeilen haben.</p>
</div>
<div class="flow-box">
<strong>1:1 Beziehung</strong>
<p class="small">Eine Personalnummer hat genau eine Detailzeile (z. B. Stammdaten).</p>
</div>
<div class="flow-box">
<strong>Filterfluss</strong>
<p class="small">Filter sollen meistens nur in eine Richtung laufen (Einweg).</p>
</div>
</div>
<h3>3.2 Beziehung erstellen</h3>
<ol>
<li>Modellansicht öffnen (Beziehungs-Icon links).</li>
<li>Spalte <strong>PERNR</strong> von Tabelle A auf Tabelle B ziehen.</li>
<li>Kardinalität prüfen (1:n) und Kreuzfilterrichtung auf Einweg setzen.</li>
</ol>
<h3>3.3 Datumstabelle erstellen</h3>
<ol>
<li><strong>Modellierung → Neue Tabelle</strong>.</li>
<li>DAX-Formel eingeben:</li>
</ol>
<pre><code>Datum = ADDCOLUMNS(
CALENDAR(DATE(2020,1,1), TODAY()),
"Jahr", YEAR([Date]),
"Monat", MONTH([Date]),
"MonatName", FORMAT([Date],"MMMM"),
"Quartal", "Q" &amp; QUARTER([Date]),
"KW", WEEKNUM([Date])
)</code></pre>
<ol start="3">
<li><strong>Tabellen-Tools → Als Datumstabelle markieren → Datum[Date]</strong>.</li>
</ol>
<h3>3.4 PERNR als Schlüssel</h3>
<ol>
<li>PERNR in allen SAP-Tabellen verwenden.</li>
<li>In Rexx/Excel dieselbe Spalte sicherstellen.</li>
<li>Bei führenden Nullen: Datentyp Text setzen (nicht Zahl).</li>
</ol>
</section>
<section>
<h2>4. DAX Measures <span class="badge">Modul 4</span></h2>
<h3>4.1 Measure vs. berechnete Spalte</h3>
<ul>
<li><strong>Measure:</strong> wird im Bericht berechnet, schneller und flexibler.</li>
<li><strong>Berechnete Spalte:</strong> wird in jeder Zeile gespeichert (macht Modell grösser).</li>
</ul>
<h3>4.2 Neues Measure erstellen</h3>
<ol>
<li><strong>Modellierung → Neues Measure</strong>.</li>
<li>Formel eingeben und Enter drücken.</li>
<li>Measure klar benennen (z. B. <code>Headcount</code>, <code>Fluktuation</code>).</li>
</ol>
<h3>4.3 Basis-Measures für HR</h3>
<pre><code>Headcount = COUNTROWS(Mitarbeiter)
FTE = SUMX(Mitarbeiter, Mitarbeiter[Beschäftigungsgrad]/100)
Krankheitstage = SUM(Abwesenheiten[Kalendertage])
Sollarbeitstage = [Headcount] * 21
Krankenquote = DIVIDE([Krankheitstage], [Sollarbeitstage], 0)
Krankenquote_ohne_LZ =
VAR KrankheitstageKurz = CALCULATE([Krankheitstage], FILTER(Abwesenheiten, Abwesenheiten[Kalendertage] <= 30))
RETURN DIVIDE(KrankheitstageKurz, [Sollarbeitstage], 0)
Austritte = CALCULATE(COUNTROWS(Mitarbeiter), Mitarbeiter[Austritt] <> BLANK())
Avg_Headcount = AVERAGEX(VALUES(Datum[Monat]), [Headcount])
Fluktuation = DIVIDE([Austritte], [Avg_Headcount], 0) * 100</code></pre>
<h3>4.4 Zeitintelligenz</h3>
<pre><code>Headcount_VJ = CALCULATE([Headcount], SAMEPERIODLASTYEAR(Datum[Date]))
Headcount_VM = CALCULATE([Headcount], PREVIOUSMONTH(Datum[Date]))
Headcount_YTD = TOTALYTD([Headcount], Datum[Date])
Delta_VJ = [Headcount] - [Headcount_VJ]
Delta_VJ_Proz = DIVIDE([Delta_VJ], [Headcount_VJ], 0)</code></pre>
<h3>4.5 Measures formatieren</h3>
<ol>
<li>Measure auswählen.</li>
<li><strong>Measure-Tools → Format</strong> (Prozent, Währung, Dezimalstellen).</li>
</ol>
<div class="callout">Tipp: Für Krankenquote Prozentformat mit 1 Dezimalstelle verwenden.</div>
</section>
<section>
<h2>5. Visualisierungen <span class="badge">Modul 5</span></h2>
<h3>5.1 Welche Visualisierung wofür?</h3>
<ul>
<li><strong>Karte/Card:</strong> Einzelne KPI-Zahl (Headcount, Fluktuation).</li>
<li><strong>Balken:</strong> Vergleich von Abteilungen/Monaten.</li>
<li><strong>Linie:</strong> Trendverlauf (Headcount über 12 Monate).</li>
<li><strong>Ring:</strong> Anteil Absenzen nach Typ.</li>
<li><strong>Tachometer:</strong> Ziel vs Ist (Stellenplan).</li>
<li><strong>Matrix:</strong> Detailansicht mit Drill-down.</li>
</ul>
<h3>5.2 Erste Visualisierung erstellen</h3>
<ol>
<li>Visualisierung auswählen (z. B. Karte).</li>
<li>Feld <code>Headcount</code> in Werte ziehen.</li>
<li>Visual rechts auf der Seite platzieren.</li>
</ol>
<h3>5.3 Visualisierung formatieren</h3>
<ol>
<li>Visual auswählen → <strong>Format</strong> (Pinsel).</li>
<li>Titel hinzufügen: „Headcount aktuell“.</li>
<li>Farben gemäss Firmen-CI setzen.</li>
</ol>
<h3>5.4 Filter &amp; Slicer</h3>
<ol>
<li>Filterbereich öffnen.</li>
<li>Feld <code>Abteilung</code> als Seitenfilter setzen.</li>
<li>Slicer für Zeitraum hinzufügen.</li>
</ol>
<div class="callout warning">Warnung: Zu viele Filter verwirren. Maximal 23 Slicer pro Seite.</div>
</section>
<section>
<h2>6. Dashboard bauen <span class="badge">Modul 6</span></h2>
<h3>6.1 Layout planen</h3>
<ol>
<li>Wichtigste KPIs oben links platzieren (F-Muster).</li>
<li>Maximal 68 Visuals pro Seite.</li>
<li>Genug Weissraum für bessere Lesbarkeit.</li>
</ol>
<h3>6.2 Management-Übersicht (Seite 1)</h3>
<ol>
<li>KPI-Karten: Headcount, Krankenquote, Fluktuation, Stellenplan.</li>
<li>Trendlinie Headcount (12 Monate).</li>
<li>Absenzquote nach Typ als Ringdiagramm.</li>
</ol>
<h3>6.3 Detailanalyse (Seite 2)</h3>
<ol>
<li>Matrix mit Drill-down nach Abteilung.</li>
<li>Slicer: Zeitraum und Kostenstelle.</li>
</ol>
<h3>6.4 Interaktionen</h3>
<ol>
<li><strong>Format → Interaktionen bearbeiten</strong>.</li>
<li>Prüfen, ob Klick auf Balken andere Visuals filtert.</li>
</ol>
</section>
<section>
<h2>7. Veröffentlichen &amp; Teilen <span class="badge">Modul 7</span></h2>
<ol>
<li><strong>Datei → Veröffentlichen → Arbeitsbereich wählen</strong>.</li>
<li>Im Service Visuals anheften → Dashboard erstellen.</li>
<li>Teilen-Link an Geschäftsleitung/Finanzen senden.</li>
<li>Geplante Aktualisierung einrichten (Gateway für lokale Daten).</li>
</ol>
<div class="callout">Tipp: Teste RLS im Service immer mit „Als Rolle anzeigen“.</div>
</section>
<section>
<h2>8. Troubleshooting <span class="badge">Modul 8</span></h2>
<h3>8.1 Häufige Import-Fehler</h3>
<ul>
<li>Umlaute falsch → Encoding auf UTF-8 stellen.</li>
<li>Datum als Text → Datentyp mit Gebietsschema Schweiz.</li>
<li>Dezimaltrennzeichen falsch → Gebietsschema prüfen.</li>
</ul>
<h3>8.2 DAX-Fehler</h3>
<ul>
<li>Zirkelbezug → berechnete Spalten vermeiden.</li>
<li>Division durch Null → <code>DIVIDE()</code> verwenden.</li>
<li>Filterkontext falsch → <code>CALCULATE()</code> prüfen.</li>
</ul>
</section>
<section>
<h2>9. Anhang: Cheat Sheet &amp; Checkliste <span class="badge">Modul 9</span></h2>
<h3>9.1 DAX Cheat Sheet</h3>
<pre><code>Headcount = COUNTROWS(Mitarbeiter)
FTE = SUMX(Mitarbeiter, Mitarbeiter[Beschäftigungsgrad]/100)
Krankenquote = DIVIDE([Krankheitstage], [Sollarbeitstage], 0)
Fluktuation = DIVIDE([Austritte], [Avg_Headcount], 0) * 100</code></pre>
<h3>9.2 Checkliste: Neuer Report</h3>
<ul class="checklist">
<li>Datenquellen klären (SAP, Rexx, Excel/CSV).</li>
<li>Daten importieren und bereinigen (Power Query).</li>
<li>Beziehungen und Datumstabelle erstellen.</li>
<li>Measures bauen und formatieren.</li>
<li>Dashboard layouten, testen, veröffentlichen.</li>
</ul>
</section>
</main>
</body>
</html>
@@ -0,0 +1,320 @@
# Power BI Schulungshandbuch für HR
Word-Version: Nicht im Repo enthalten (Binary-Dateien werden beim PR-Erstellen nicht unterstützt).
Zielgruppe: 34 HR-Mitarbeiterinnen (Schweiz), Excel-Basis + SVERWEIS, Technikaffinität 56/10, keine Power BI Vorkenntnisse.
Datenquellen: SAP HCM/HRM (PA0001, PA0002, PA0008, PA2001), Rexx HR-System (Stellenplan, Pulsumfrage, MA-Zufriedenheit), Excel/CSV (Kununu-Score, Refline/Time-to-hire).
KPIs: Headcount/FTE (monatlich), Fluktuation (monatlich), Krankenquote gesamt & ohne Langzeit >30 Tage (Quartal), Überstunden (Quartal), Produktivstunden (wöchentlich), Ferientage/GLZ-Saldi (jährlich), Stellenplan Soll vs Ist (monatlich), Lohnkosten (monatlich), Time to hire (Quartal), Kununu Score (monatlich), Pulsumfrage (Quartal), MA-Zufriedenheitsumfrage (jährlich).
Zielgruppen der Reports: Geschäftsleitung, Verwaltungsrat, Finanzbuchhaltung, Abteilungsleiter.
## 1. MODUL 1: GRUNDLAGEN & DATENIMPORT
### 1.1 Power BI Desktop installieren und starten
1. Schritt: Gehe auf https://powerbi.microsoft.com/de-de/desktop/ und lade Power BI Desktop herunter.
2. Schritt: Installiere die Anwendung mit den Standardoptionen (Weiter → Installieren → Fertigstellen).
3. Schritt: Starte Power BI Desktop über das Startmenü.
[Screenshot: Startfenster von Power BI Desktop mit leeren Berichtsvorlagen].
Tipp: Wenn der Download blockiert ist, wende Dich an die IT (Admin-Rechte erforderlich).
### 1.2 Oberfläche kennenlernen
1. Schritt: Wechsle links zwischen Berichtsansicht, Datenansicht und Modellansicht.
2. Schritt: Erkenne die Bereiche: Menüband oben, Visualisierungen rechts, Felder-Bereich rechts, Seiten-Navigation links.
3. Schritt: Klicke auf eine leere Seite, damit Visualisierungen verfügbar werden.
[Screenshot: Power BI Desktop mit markierter Berichtsansicht, Visualisierungen und Felder-Bereich].
### 1.3 Excel-Datei importieren
1. Schritt: Reiter → Start → Daten abrufen → Excel.
2. Schritt: Datei auswählen → Öffnen.
3. Schritt: Im Navigator Tabelle oder Blatt auswählen → Laden.
Warnung: Wenn Du im Navigator mehrere Tabellen auswählst, kann die Ladezeit steigen.
Häufige Probleme und Lösungen:
1. Problem: Falsche Spaltennamen → Lösung: Erste Zeile als Header setzen (siehe Modul 2).
2. Problem: Zahlen als Text → Lösung: Datentyp korrigieren (siehe Modul 2).
### 1.4 CSV importieren
1. Schritt: Reiter → Start → Daten abrufen → Text/CSV.
2. Schritt: Datei auswählen → Öffnen.
3. Schritt: Im Vorschaufenster Trennzeichen und Kodierung prüfen.
Warnung: In der Schweiz sind Umlaute oft nur mit UTF-8 korrekt. Stelle Kodierung auf UTF-8, falls nötig.
Hinweis: CSV hat keine Formeln oder Formatierungen nur Rohdaten.
### 1.5 SAP-Export importieren
1. Schritt: SAP-Export (z. B. TXT/CSV/XLSX) in einen lokalen Ordner speichern.
2. Schritt: Reiter → Start → Daten abrufen → Text/CSV oder Excel wählen.
3. Schritt: Im Navigator prüfen, ob die erste Zeile die Spaltenüberschriften enthält.
Tipp: Wenn die Überschriften fehlen, nutze Power Query → Erste Zeile als Überschriften.
## 2. MODUL 2: POWER QUERY EDITOR
### 2.1 Power Query öffnen
1. Schritt: Reiter → Start → Daten transformieren.
[Screenshot: Button 'Daten transformieren' im Menüband].
### 2.2 Erste Zeile als Header verwenden
1. Schritt: Reiter → Transformieren → Erste Zeile als Überschriften.
2. Schritt: Prüfe, ob die Spaltennamen korrekt sind.
### 2.3 Datentypen ändern
1. Schritt: Spalte auswählen (z. B. Eintrittsdatum).
2. Schritt: Reiter → Transformieren → Datentyp → Datum.
3. Schritt: Bei Zahlen Datentyp → Dezimalzahl oder Ganze Zahl.
Warnung: Schweizer Datumsformat (TT.MM.JJJJ) wird manchmal als Text erkannt. In diesem Fall zuerst Datentyp Text, dann Datum mit Gebietsschema Schweiz (Deutsch).
### 2.4 Spalten entfernen/behalten
1. Schritt: Unnötige Spalten markieren.
2. Schritt: Reiter → Start → Spalten entfernen.
Tipp: Nutze "Andere Spalten entfernen", um nur relevante Spalten zu behalten.
### 2.5 Zeilen filtern
1. Schritt: Filterpfeil in der Spalte Status.
2. Schritt: Nur aktive Mitarbeitende auswählen.
3. Schritt: Zeitraumfilter z. B. letztes Jahr.
### 2.6 Werte ersetzen
1. Schritt: Reiter → Transformieren → Werte ersetzen.
2. Schritt: null durch 0 ersetzen.
3. Schritt: Codes (z. B. 'A') durch Klartext (z. B. 'Aktiv') ersetzen.
### 2.7 Spalten teilen/zusammenführen
1. Schritt: Spalte auswählen.
2. Schritt: Reiter → Transformieren → Spalte teilen (nach Trennzeichen).
3. Schritt: Für Zusammenführen: Reiter → Transformieren → Spalten zusammenführen.
### 2.8 Berechnete Spalte hinzufügen
1. Schritt: Reiter → Spalte hinzufügen → Benutzerdefinierte Spalte.
2. Schritt: Formel eingeben (z. B. Beschäftigungsgrad/100).
### 2.9 Schliessen und Laden
1. Schritt: Reiter → Start → Schliessen & laden.
2. Schritt: Unterschied: "Laden" speichert in Modell, "Laden in" erlaubt gezielte Ziele (z. B. nur Verbindung).
## 3. MODUL 3: DATENMODELL
### 3.1 Zur Modellansicht wechseln
1. Schritt: Links auf die Modellansicht (Beziehungs-Icon) klicken.
### 3.2 Beziehungen verstehen
1. Schritt: 1:n = Eine Zeile in Tabelle A passt zu vielen Zeilen in Tabelle B.
2. Schritt: 1:1 = Jede Zeile passt genau zu einer anderen Zeile.
Warum wichtig: Beziehungen steuern, wie Filter zwischen Tabellen fliessen.
### 3.3 Beziehung erstellen
1. Schritt: Spalte in Tabelle A auf passende Spalte in Tabelle B ziehen (Drag & Drop).
2. Schritt: Beziehung prüfen → Kardinalität und Kreuzfilterrichtung einstellen.
Tipp: Nutze meistens Einweg-Filterrichtung, um Mehrdeutigkeiten zu vermeiden.
### 3.4 Datumstabelle erstellen
1. Schritt: Reiter → Modellierung → Neue Tabelle.
2. Schritt: DAX-Formel einfügen:
```
Datum = ADDCOLUMNS(CALENDAR(DATE(2020,1,1), TODAY()), "Jahr", YEAR([Date]), "Monat", MONTH([Date]), "MonatName", FORMAT([Date],"MMMM"), "Quartal", "Q" & QUARTER([Date]), "KW", WEEKNUM([Date]))
```
3. Schritt: Reiter → Tabellen-Tools → Als Datumstabelle markieren → Datum[Date] auswählen.
### 3.5 PERNR als Schlüssel
1. Schritt: Verwende die Personalnummer (PERNR) als Schlüssel zwischen allen SAP-Tabellen (PA0001, PA0002, PA0008, PA2001).
## 4. MODUL 4: DAX MEASURES
### 4.1 Was ist ein Measure vs. berechnete Spalte
1. Schritt: Measure berechnet sich dynamisch im Berichtskontext.
2. Schritt: Berechnete Spalte wird pro Zeile gespeichert und erhöht Modellgrösse.
### 4.2 Neues Measure erstellen
1. Schritt: Reiter → Modellierung → Neues Measure.
2. Schritt: Formel eingeben und mit Enter bestätigen.
### 4.3 Basis-Measures für HR
```
Headcount = COUNTROWS(Mitarbeiter)
FTE = SUMX(Mitarbeiter, Mitarbeiter[Beschäftigungsgrad]/100)
Krankheitstage = SUM(Abwesenheiten[Kalendertage])
Sollarbeitstage = [Headcount] * 21
Krankenquote = DIVIDE([Krankheitstage], [Sollarbeitstage], 0)
Krankenquote_ohne_LZ =
VAR KrankheitstageKurz = CALCULATE([Krankheitstage], FILTER(Abwesenheiten, Abwesenheiten[Kalendertage] <= 30))
RETURN DIVIDE(KrankheitstageKurz, [Sollarbeitstage], 0)
Austritte = CALCULATE(COUNTROWS(Mitarbeiter), Mitarbeiter[Austritt] <> BLANK())
Avg_Headcount = AVERAGEX(VALUES(Datum[Monat]), [Headcount])
Fluktuation = DIVIDE([Austritte], [Avg_Headcount], 0) * 100
```
### 4.4 Zeitintelligenz-Measures
```
Headcount_VJ = CALCULATE([Headcount], SAMEPERIODLASTYEAR(Datum[Date]))
Headcount_VM = CALCULATE([Headcount], PREVIOUSMONTH(Datum[Date]))
Headcount_YTD = TOTALYTD([Headcount], Datum[Date])
Delta_VJ = [Headcount] - [Headcount_VJ]
Delta_VJ_Proz = DIVIDE([Delta_VJ], [Headcount_VJ], 0)
```
### 4.5 Measures formatieren
1. Schritt: Measure auswählen.
2. Schritt: Reiter → Measure-Tools → Format → Prozent, Dezimalstellen, Währung einstellen.
## 5. MODUL 5: VISUALISIERUNGEN
### 5.1 Visualisierungstypen und wann verwenden
1. Karte/Card: Einzelne KPI-Zahl (Headcount, Krankenquote).
2. Balkendiagramm: Vergleiche (Abteilungen, Monate).
3. Liniendiagramm: Zeitverläufe (Headcount über 12 Monate).
4. Ringdiagramm: Anteile (Absenzen nach Typ).
5. Tachometer: Ziel vs Ist (Stellenplan-Erfüllung).
6. Tabelle/Matrix: Details mit Drill-down.
### 5.2 Erste Visualisierung erstellen
1. Schritt: Visualisierung im Bereich Visualisierungen auswählen.
2. Schritt: Felder per Drag & Drop in Achse/Werte ziehen.
3. Schritt: Visualisierung auf der Seite positionieren.
### 5.3 Visualisierung formatieren
1. Schritt: Visual auswählen → Reiter Visual → Format (Pinsel).
2. Schritt: Titel, Farben, Schriftgrössen anpassen.
### 5.4 Filter hinzufügen
1. Schritt: Filterbereich öffnen.
2. Schritt: Felder in Visualfilter, Seitenfilter oder Berichtsfilter ziehen.
### 5.5 Slicer erstellen
1. Schritt: Visualisierung → Datenschnitt (Slicer) wählen.
2. Schritt: Feld (z. B. Zeitraum, Abteilung) hinzufügen.
### 5.6 Bedingte Formatierung
1. Schritt: In Tabelle/Matrix auf Wertefeld klicken → Bedingte Formatierung.
2. Schritt: Regeln definieren (z. B. Rot/Grün je nach Wert).
Tipp: Ampel-Logik funktioniert gut für Krankenquote und Fluktuation.
## 6. MODUL 6: DASHBOARD BAUEN
### 6.1 Dashboard-Layout planen
1. Schritt: F-Muster beachten Wichtigstes oben links.
2. Schritt: Maximal 68 Visualisierungen pro Seite.
### 6.2 Seite 1: Management-Übersicht erstellen
1. Schritt: KPI-Karten oben: Headcount, Krankenquote, Fluktuation, Stellenplan.
2. Schritt: Trendlinie Headcount über 12 Monate.
3. Schritt: Absenzquote nach Typ als Ringdiagramm.
### 6.3 Seite 2: Detailanalyse erstellen
1. Schritt: Matrix mit Drill-down nach Abteilung.
2. Schritt: Filter für Zeitraum und Kostenstelle (Slicer).
### 6.4 Interaktionen zwischen Visualisierungen
1. Schritt: Reiter → Format → Interaktionen bearbeiten.
2. Schritt: Prüfen, ob Klick auf Balken andere Visuals filtert oder hervorhebt.
### 6.5 Design-Tipps
1. Schritt: Konsistente Farben (Firmen-CI).
2. Schritt: Genügend Weissraum.
3. Schritt: Beschriftungen gut lesbar.
## 7. MODUL 7: VERÖFFENTLICHEN & TEILEN
### 7.1 Power BI Service (app.powerbi.com)
1. Schritt: Konto erstellen/anmelden.
2. Schritt: Unterschied Desktop vs Service: Desktop = Modell/Bericht, Service = Teilen/Dashboard.
### 7.2 Bericht veröffentlichen
1. Schritt: Reiter → Datei → Veröffentlichen → Arbeitsbereich wählen.
### 7.3 Arbeitsbereich einrichten
1. Schritt: Im Service → Arbeitsbereich erstellen.
2. Schritt: Zugriffsrechte für Geschäftsleitung/Finanzbuchhaltung setzen.
### 7.4 Dashboard erstellen (aus Bericht)
1. Schritt: Im Service Visualisierung auswählen → Anheften.
2. Schritt: Neues Dashboard erstellen oder bestehendes wählen.
### 7.5 Bericht teilen
1. Schritt: Teilen → Link generieren.
2. Schritt: Zugriff verwalten (Rollen/Personen).
### 7.6 Automatische Aktualisierung einrichten
1. Schritt: Datensatz → Geplante Aktualisierung (täglich/wöchentlich).
2. Schritt: Für lokale Daten Gateway einrichten (IT einbeziehen).
### 7.7 Row-Level Security (RLS)
1. Schritt: Reiter → Modellierung → Rollen verwalten.
2. Schritt: Rolle erstellen, Filter setzen: [Abteilung] = USERPRINCIPALNAME().
Warnung: RLS muss im Service getestet werden (Als Rolle anzeigen).
## 8. TROUBLESHOOTING
### 8.1 Häufige Fehler beim Import
1. Problem: Encoding-Probleme (UTF-8) → Lösung: Kodierung im CSV-Import anpassen.
2. Problem: Dezimaltrennzeichen (Punkt vs Komma) → Lösung: Datentyp mit Gebietsschema Schweiz setzen.
3. Problem: Datum als Text → Lösung: Datentyp Datum und richtiges Gebietsschema.
### 8.2 Häufige DAX-Fehler
1. Problem: Zirkelbezug → Lösung: Berechnete Spalten vermeiden, Measures nutzen.
2. Problem: Division durch Null → Lösung: DIVIDE() verwenden.
3. Problem: Falscher Filterkontext → Lösung: Filter mit CALCULATE prüfen.
### 8.3 Beziehungsprobleme
1. Problem: Mehrdeutige Beziehungen → Lösung: Eine Beziehung aktiv, andere inaktiv setzen.
2. Problem: Fehlende Beziehung → Lösung: Schlüsselspalten prüfen (PERNR, Datum).
### 8.4 Performance-Probleme
1. Problem: Zu viele Spalten importiert → Lösung: Spalten reduzieren.
2. Problem: Zu viele berechnete Spalten → Lösung: Measures bevorzugen.
## 9. ANHANG
### 9.1 DAX Cheat Sheet (alle HR-Formeln)
```
Headcount = COUNTROWS(Mitarbeiter)
FTE = SUMX(Mitarbeiter, Mitarbeiter[Beschäftigungsgrad]/100)
Krankheitstage = SUM(Abwesenheiten[Kalendertage])
Sollarbeitstage = [Headcount] * 21
Krankenquote = DIVIDE([Krankheitstage], [Sollarbeitstage], 0)
Krankenquote_ohne_LZ = VAR KrankheitstageKurz = CALCULATE([Krankheitstage], FILTER(Abwesenheiten, Abwesenheiten[Kalendertage] <= 30))
RETURN DIVIDE(KrankheitstageKurz, [Sollarbeitstage], 0)
Austritte = CALCULATE(COUNTROWS(Mitarbeiter), Mitarbeiter[Austritt] <> BLANK())
Avg_Headcount = AVERAGEX(VALUES(Datum[Monat]), [Headcount])
Fluktuation = DIVIDE([Austritte], [Avg_Headcount], 0) * 100
Headcount_VJ = CALCULATE([Headcount], SAMEPERIODLASTYEAR(Datum[Date]))
Headcount_VM = CALCULATE([Headcount], PREVIOUSMONTH(Datum[Date]))
Headcount_YTD = TOTALYTD([Headcount], Datum[Date])
Delta_VJ = [Headcount] - [Headcount_VJ]
Delta_VJ_Proz = DIVIDE([Delta_VJ], [Headcount_VJ], 0)
```
### 9.2 Checkliste: Neuen Report erstellen
1. Schritt: Datenquellen klären (SAP, Rexx, Excel/CSV).
2. Schritt: Daten importieren (Modul 1).
3. Schritt: Daten bereinigen in Power Query (Modul 2).
4. Schritt: Beziehungen und Datumstabelle erstellen (Modul 3).
5. Schritt: Measures erstellen (Modul 4).
6. Schritt: Visuals bauen und formatieren (Modul 5).
7. Schritt: Dashboard layouten (Modul 6).
8. Schritt: Veröffentlichen und teilen (Modul 7).
### 9.3 Glossar
Power Query: Datenaufbereitungstool in Power BI.
DAX: Formelsprache für Berechnungen in Power BI.
Measure: Dynamische Kennzahl, abhängig vom Filterkontext.
Berechnete Spalte: Feste Berechnung pro Zeile.
RLS: Row-Level Security für zeilenbasierte Zugriffssteuerung.
@@ -0,0 +1,258 @@
<!DOCTYPE html>
<html lang="de-CH">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Power BI Schulungsleitfaden für Trainer (ABAP/Webservices)</title>
<style>
:root {
color-scheme: light;
--accent: #1f6feb;
--accent-soft: #e0f2fe;
--text: #0f172a;
--muted: #475569;
--bg: #f8fafc;
--card: #ffffff;
--border: #e2e8f0;
--warning: #f97316;
--success: #16a34a;
--code: #0b1020;
}
body {
margin: 0;
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
color: var(--text);
background: var(--bg);
line-height: 1.7;
}
header {
background: linear-gradient(120deg, #e0f2fe 0%, #eef2ff 100%);
padding: 40px 24px 24px;
border-bottom: 1px solid var(--border);
}
header h1 {
margin: 0 0 8px 0;
font-size: 2.1rem;
}
header p {
margin: 6px 0;
color: var(--muted);
}
main {
max-width: 1050px;
margin: 0 auto;
padding: 24px;
}
section {
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 26px;
margin-bottom: 22px;
box-shadow: 0 4px 18px rgba(15, 23, 42, 0.05);
}
h2 {
margin-top: 0;
color: #111827;
border-bottom: 2px solid var(--border);
padding-bottom: 6px;
}
h3 {
margin-bottom: 6px;
color: #1e293b;
}
h4 {
margin: 14px 0 6px;
color: #1f2937;
}
ul, ol {
margin: 8px 0 16px 24px;
}
.callout {
border-left: 4px solid var(--accent);
background: #eef2ff;
padding: 12px 16px;
border-radius: 8px;
margin: 12px 0;
color: #1e293b;
}
.warning {
border-left-color: var(--warning);
background: #fff7ed;
}
.success {
border-left-color: var(--success);
background: #ecfdf3;
}
pre {
background: var(--code);
color: #e2e8f0;
padding: 16px;
border-radius: 10px;
overflow-x: auto;
}
code {
font-family: "Consolas", "Courier New", monospace;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
}
th, td {
border: 1px solid var(--border);
padding: 10px 12px;
text-align: left;
}
th {
background: #f1f5f9;
font-weight: 600;
}
.small {
font-size: 0.92rem;
color: var(--muted);
}
</style>
</head>
<body>
<header>
<h1>Power BI Schulungsleitfaden für Trainer (ABAP &amp; Daten-Webservices)</h1>
<p>Rolle: Trainer/IT/BI (ABAP-Expertise). Fokus auf Datenbereitstellung, Webservices und Betriebsübergabe.</p>
<p class="small">Zielgruppe der Schulung: HR-Konsumentinnen (Power BI Service, Filtern, Export).</p>
</header>
<main>
<section>
<h2>Zielbild</h2>
<ul>
<li>HR konsumiert fertige Dashboards im Power BI Service.</li>
<li>IT/BI stellt Datenquellen bereit, pflegt Modell, DAX, Refresh, Rechte.</li>
<li>Stabiler, dokumentierter Datenfluss (SAP → Webservice → Power BI).</li>
</ul>
<div class="callout success">Ergebnis: HR arbeitet schneller, IT/BI bleibt Owner von Datenqualität und Logik.</div>
</section>
<section>
<h2>Best Practice Rollenverteilung</h2>
<table>
<thead>
<tr>
<th>Aufgabe</th>
<th>HR</th>
<th>IT/BI-Team</th>
</tr>
</thead>
<tbody>
<tr><td>KPIs definieren</td><td></td><td></td></tr>
<tr><td>Daten interpretieren</td><td></td><td></td></tr>
<tr><td>Reports anfordern</td><td></td><td></td></tr>
<tr><td>Dashboards bauen</td><td></td><td></td></tr>
<tr><td>DAX/Measures schreiben</td><td></td><td></td></tr>
<tr><td>Datenmodell pflegen</td><td></td><td></td></tr>
<tr><td>Fertige Dashboards nutzen</td><td></td><td></td></tr>
<tr><td>Filter setzen, Drill-down</td><td></td><td></td></tr>
</tbody>
</table>
</section>
<section>
<h2>Datenquellen &amp; Webservices: Architektur</h2>
<p>Empfohlen für SAP-HR: OData/REST-Webservices aus SAP bereitstellen, dann in Power BI Service via Gateway anbinden.</p>
<ol>
<li>SAP HCM/HRM (PA0001/PA0002/PA0008/PA2001) → ABAP CDS/OData.</li>
<li>Rexx HR-System → REST/CSV-Exports oder DB-View.</li>
<li>Excel/CSV (Kununu, Refline) → SharePoint/OneDrive Ordner.</li>
<li>Power BI Dataset → Bericht → Dashboard.</li>
</ol>
<div class="callout">Ziel: Quellen entkoppeln, standardisierte Schnittstellen, minimale manuelle Exporte.</div>
</section>
<section>
<h2>SAP → Webservice: Vorgehen (ABAP)</h2>
<h3>1) CDS View mit sauberem Datenmodell</h3>
<ul>
<li>Erstelle CDS Views je Fachthema (z. B. Personalstamm, Absenzen, Lohn).</li>
<li>PERNR als Schlüssel, Datum als ISO-Format (YYYY-MM-DD).</li>
<li>Sprache und Mandant berücksichtigen.</li>
</ul>
<h3>2) OData Service veröffentlichen</h3>
<ul>
<li>Expose CDS als OData (Fiori Elements oder Gateway).</li>
<li>Aktiviere in /IWFND/MAINT_SERVICE.</li>
<li>Setze Authentifizierung (SAML/OAuth/Basic nach IT-Policy).</li>
</ul>
<h3>3) Performance &amp; Paging</h3>
<ul>
<li>Paging aktivieren, Delta-Logik prüfen.</li>
<li>Nur benötigte Felder liefern (Thin Views).</li>
<li>Filter serverseitig ermöglichen (Datum, Mandant, Status).</li>
</ul>
<div class="callout warning">Warnung: Zu viele Felder oder fehlende Filter führen zu langsamen Refreshs.</div>
</section>
<section>
<h2>Power BI Service: Datenanbindung</h2>
<h3>Gateway &amp; Authentifizierung</h3>
<ol>
<li>On-Premise Data Gateway installieren (IT/BI-Team).</li>
<li>Datenquelle registrieren (SAP OData/REST URL).</li>
<li>Zugangsdaten hinterlegen (Servicekonto).</li>
</ol>
<h3>Dataset Konfiguration</h3>
<ol>
<li>Power BI Desktop: Web/OData Connector nutzen.</li>
<li>Query-Parameter für Zeitraum/Delta definieren.</li>
<li>Dataset veröffentlichen → Service → geplante Aktualisierung.</li>
</ol>
<div class="callout success">Tipp: Einmalige Parameter (z. B. Startdatum) reduzieren Datenvolumen.</div>
</section>
<section>
<h2>Refresh-Strategie</h2>
<ul>
<li>Monatliche KPIs: Refresh täglich oder wöchentlich.</li>
<li>Wöchentliche KPIs: Refresh täglich (MoFr).</li>
<li>Jährliche KPIs: Refresh monatlich.</li>
</ul>
<div class="callout">Empfehlung: Einen fixen Refresh-Zeitpunkt kommunizieren (z. B. 06:00 Uhr).</div>
</section>
<section>
<h2>Security &amp; Datenschutz</h2>
<ul>
<li>Row-Level Security für Abteilungen (wenn nötig).</li>
<li>HR-Reports in separatem Workspace (Zugriffsgruppen).</li>
<li>Keine sensiblen Felder im Dataset (z. B. AHV-Nummern).</li>
</ul>
<div class="callout warning">Warnung: Personalnummern als Text behandeln (führende Nullen behalten).</div>
</section>
<section>
<h2>Trainer-Checkliste vor dem Kurs</h2>
<ul>
<li>Power BI Service Zugriff für HR geprüft.</li>
<li>Mindestens 1 Testbericht bereitgestellt.</li>
<li>Refresh läuft &amp; Daten aktuell.</li>
<li>Kurzanleitung für Filter/Export vorbereitet.</li>
</ul>
</section>
<section>
<h2>FAQ aus Sicht HR (Trainer-Antworten)</h2>
<h3>„Warum stimmen Zahlen nicht?“</h3>
<p>Meist ist ein Filter aktiv. Bitte Filter zurücksetzen und Zeitraum prüfen.</p>
<h3>„Warum sehe ich keine Daten?“</h3>
<p>Entweder fehlen Berechtigungen oder der Zeitraum ist zu eng gesetzt.</p>
<h3>„Kann ich Daten ändern?“</h3>
<p>Nein. HR konsumiert, Datenpflege erfolgt in SAP/Rexx/IT.</p>
</section>
</main>
</body>
</html>