@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 @T("HR KPI", "HR KPI") @T("HR KPI", "HR KPI") @if (!CanShowHrKpi) { @T("HR KPI enthaelt sensible Personaldaten. Bitte separat anmelden.", "HR KPI contains sensitive HR data. Please sign in separately.") @if (!HrKpiAccess.IsConfigured) { @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.") }
@T("Server-Klicks", "Server clicks"): @_unlockClickCount | @T("Konfiguriert", "Configured"): @(HrKpiAccess.IsConfigured ? "JA" : "NEIN") @T("Passwort speichern", "Save password")
} else { @T("Erwartete Dateien", "Expected files"): @string.Join(", ", ExpectedUploadFileNames) @T("Uploadziel", "Upload target"): @_serverUploadFolder @T("Serverordner nutzen", "Use server folder") @foreach (var option in _result?.ExitYearOptions ?? []) { @option } @foreach (var option in _result?.OrganisationOptions ?? []) { @option } @(_loading ? T("Lade...", "Loading...") : T("Laden", "Load")) @foreach (var option in _result?.EntryYearOptions ?? []) { @option } @foreach (var option in _result?.KostenstelleOptions ?? []) { @option } @foreach (var option in _result?.MitarbeitertypOptions ?? []) { @option } @foreach (var option in _fluktuationOptions) { @option.Label } @foreach (var option in _ampelOptions) { @option } @foreach (var option in _restferienOptions) { @option } @T("Sperren", "Lock") @T("Drucken/PDF", "Print/PDF") @foreach (var variant in _variantNames) { @variant } @T("Variante speichern", "Save variant") @T("Umbenennen", "Rename") @T("Variante laden", "Load variant") @T("Löschen", "Delete") @T("Bestehende anpassen", "Update existing") } @if (CanShowHrKpi && _result is not null) { @if (_result.Notices.Count > 0) { @foreach (var notice in _result.Notices) { @notice } } } @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 _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 _ampelOptions = ["Gruen", "Gelb", "Rot"]; private readonly List _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(); 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 ReadSelectionStoreAsync() { await SelectionStoreLock.WaitAsync(); try { if (!File.Exists(_selectionStorePath)) return new HrKpiSelectionStore(); await using var stream = File.OpenRead(_selectionStorePath); return await JsonSerializer.DeserializeAsync(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 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; } } }