diff --git a/TrafagSalesExporter/Components/HrKpi/HrKpiDashboardTabs.razor b/TrafagSalesExporter/Components/HrKpi/HrKpiDashboardTabs.razor index b0a8c6e..992508f 100644 --- a/TrafagSalesExporter/Components/HrKpi/HrKpiDashboardTabs.razor +++ b/TrafagSalesExporter/Components/HrKpi/HrKpiDashboardTabs.razor @@ -7,6 +7,15 @@ @MetricGrid(Result.Metrics) + + + @TrafficLightPanel(Result.TrafficLights) + + + @MetricGrid(Result.PeriodComparisonMetrics) + + + @HeadcountByOrganisationTable(Result.HeadcountByOrganisation) @@ -29,6 +38,15 @@ + + + @GroupValueTable(T("Austritte nach Austrittsart", "Leavers by exit type"), Result.LeaversByType, T("Austritte", "Leavers")) + + + @GroupValueTable(T("Austritte nach Organisation", "Leavers by organisation"), Result.LeaversByOrganisation, T("Austritte", "Leavers")) + + + @TurnoverGauge(Result.TurnoverVisuals) @@ -51,8 +69,21 @@ + + @TrafficLightPanel(Result.TrafficLights) + @MetricGrid(Result.PeriodComparisonMetrics) + + @MetricGrid(Result.AbsenceMetrics) + + + @GroupValueTable(T("Absenzen nach Organisation", "Absences by organisation"), Result.AbsenceByOrganisation, T("Krankheitstage", "Sick days")) + + + @TopAbsencesTable(Result.Absences) + + @T("Absenzen je Mitarbeiter", "Absences by employee") @@ -67,7 +98,7 @@ @context.Personalnummer - @context.Name + @DisplayPersonName(context.Name, context.Personalnummer, Result.Options.ManagementView) @context.Organisationseinheit @context.KrankheitstageKurz.ToString("N1") @context.KrankheitstageLang.ToString("N1") @@ -100,7 +131,7 @@ @T("Ampel", "Status") - @context.NameVoll + @DisplayPersonName(context.NameVoll, context.Personalnummer, Result.Options.ManagementView) @context.Organisationseinheit @context.UrlaubRest.ToString("N1") @context.FerienAusstehend.ToString("N1") @@ -122,6 +153,11 @@ @FileStatusTable(Result.FileStatuses) + + + @DataQualityTable(Result.DataQualityIssues) + + @@ -145,6 +181,19 @@ _ => 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") ?? "-"; @@ -177,6 +226,93 @@ ; + private RenderFragment> TrafficLightPanel => items => @ + @T("HR-Ampel", "HR status") + + @foreach (var item in items) + { + + + + + @item.Status + + @item.Area + + @item.Value + @item.Detail + + + } + + ; + + private RenderFragment> DataQualityTable => items => @ + @T("Datenqualitaet", "Data quality") + + + @T("Schwere", "Severity") + @T("Bereich", "Area") + @T("Pruefpunkt", "Check") + @T("Anzahl", "Count") + @T("Hinweis", "Note") + + + + + @context.Severity + + + @context.Area + @context.Issue + @context.Count.ToString("N0") + @context.Detail + + + @T("Keine Datenqualitaetswarnungen.", "No data quality warnings.") + + + ; + + private RenderFragment<(string Title, IReadOnlyList Items, string ValueLabel)> GroupValueTableTuple => data => @ + @data.Title + + + @T("Gruppe", "Group") + @data.ValueLabel + % + + + @context.Label + @(context.Value != 0 ? context.Value.ToString("N1") : context.Count.ToString("N0")) + @context.Percent.ToString("N1") + + + ; + + private RenderFragment GroupValueTable(string title, IReadOnlyList items, string valueLabel) + => GroupValueTableTuple((title, items, valueLabel)); + + private RenderFragment> TopAbsencesTable => items => @ + @T("Hoechste Absenzen", "Highest absences") + + + @T("Name", "Name") + @T("Organisation", "Organisation") + @T("Kurz", "Short") + @T("Lang", "Long") + @T("Gesamt", "Total") + + + @DisplayPersonName(context.Name, context.Personalnummer, Result.Options.ManagementView) + @context.Organisationseinheit + @context.KrankheitstageKurz.ToString("N1") + @context.KrankheitstageLang.ToString("N1") + @context.KrankheitstageGesamt.ToString("N1") + + + ; + private RenderFragment> CriticalBalancesTable => items => @ @T("Kritische GLZ-Saldi", "Critical time balances") @@ -187,7 +323,7 @@ @T("Ampel", "Status") - @context.NameVoll + @DisplayPersonName(context.NameVoll, context.Personalnummer, Result.Options.ManagementView) @context.Organisationseinheit @context.StundenSaldo.ToString("N1") @@ -209,7 +345,7 @@ @T("Austrittsart", "Exit type") - @context.NameVoll + @DisplayPersonName(context.NameVoll, context.Personalnummer, Result.Options.ManagementView) @FormatDate(context.Austrittsdatum) @context.Organisationseinheit @context.Austrittsart @@ -246,7 +382,7 @@ @context.Personalnummer - @context.NameVoll + @DisplayPersonName(context.NameVoll, context.Personalnummer, Result.Options.ManagementView) @context.Organisationseinheit @context.KostenstelleText @context.Fte.ToString("N2") @@ -266,6 +402,8 @@ @T("Quelle", "Source") @T("Status", "Status") + @T("Stand", "Modified") + @T("Alter", "Age") @T("Zeilen", "Rows") @@ -278,6 +416,8 @@ @(context.Message ?? "-") + @FormatDate(context.LastModified) + @(context.AgeDays.HasValue ? $"{context.AgeDays:N0} Tage / {context.FreshnessStatus}" : "-") @context.RowCount.ToString("N0") diff --git a/TrafagSalesExporter/Components/Pages/HrKpi.razor b/TrafagSalesExporter/Components/Pages/HrKpi.razor index 4a212f0..500de21 100644 --- a/TrafagSalesExporter/Components/Pages/HrKpi.razor +++ b/TrafagSalesExporter/Components/Pages/HrKpi.razor @@ -7,6 +7,7 @@ @inject IHrKpiAccessService HrKpiAccess @inject ISnackbar Snackbar @inject IUiTextService UiText +@inject IJSRuntime JsRuntime @T("HR KPI", "HR KPI") @@ -65,6 +66,10 @@ else @(_loading ? T("Lade...", "Loading...") : T("Laden", "Load")) + + + @@ -128,6 +133,12 @@ else @T("Sperren", "Lock") + + + @T("Drucken/PDF", "Print/PDF") + + } @@ -161,6 +172,7 @@ else private string? _glzAmpel; private string? _restferienAmpel; private string? _searchText; + private bool _managementView; private string? _hrUsername; private string? _hrPassword; private bool _loading; @@ -207,7 +219,8 @@ else FluktuationFilter = _fluktuationFilter, GlzAmpel = _glzAmpel, RestferienAmpel = _restferienAmpel, - SearchText = _searchText + SearchText = _searchText, + ManagementView = _managementView }); } catch (Exception ex) @@ -239,6 +252,11 @@ else _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); diff --git a/TrafagSalesExporter/Models/HrKpiModels.cs b/TrafagSalesExporter/Models/HrKpiModels.cs index 2e689da..8a7ae19 100644 --- a/TrafagSalesExporter/Models/HrKpiModels.cs +++ b/TrafagSalesExporter/Models/HrKpiModels.cs @@ -14,6 +14,7 @@ public sealed class HrKpiOptions public string? GlzAmpel { get; set; } public string? RestferienAmpel { get; set; } public string? SearchText { get; set; } + public bool ManagementView { get; set; } } public sealed class HrKpiDataSourceOptions @@ -57,6 +58,13 @@ public sealed class HrKpiResult public List TurnoverMetrics { get; set; } = []; public List AbsenceMetrics { get; set; } = []; public List TimeVacationMetrics { get; set; } = []; + public List PeriodComparisonMetrics { get; set; } = []; + public List TrafficLights { get; set; } = []; + public List DataQualityIssues { get; set; } = []; + public List LeaversByType { get; set; } = []; + public List LeaversByOrganisation { get; set; } = []; + public List AbsenceByOrganisation { get; set; } = []; + public List CriticalAbsences { get; set; } = []; public List Employees { get; set; } = []; public List Absences { get; set; } = []; public List Leavers { get; set; } = []; @@ -73,6 +81,26 @@ public sealed class HrKpiFileStatus public bool Exists { get; set; } public int RowCount { get; set; } public string? Message { get; set; } + public DateTime? LastModified { get; set; } + public int? AgeDays { get; set; } + public string FreshnessStatus { get; set; } = "Unbekannt"; +} + +public sealed class HrKpiTrafficLight +{ + public string Area { get; set; } = string.Empty; + public string Status { get; set; } = "Gruen"; + public string Value { get; set; } = string.Empty; + public string Detail { get; set; } = string.Empty; +} + +public sealed class HrKpiDataQualityIssue +{ + public string Severity { get; set; } = "Info"; + public string Area { get; set; } = string.Empty; + public string Issue { get; set; } = string.Empty; + public int Count { get; set; } + public string Detail { get; set; } = string.Empty; } public sealed class HrKpiMetric diff --git a/TrafagSalesExporter/Services/HrKpi/HrKpiDashboardBuilder.cs b/TrafagSalesExporter/Services/HrKpi/HrKpiDashboardBuilder.cs index 39c3537..7a21ebb 100644 --- a/TrafagSalesExporter/Services/HrKpi/HrKpiDashboardBuilder.cs +++ b/TrafagSalesExporter/Services/HrKpi/HrKpiDashboardBuilder.cs @@ -38,7 +38,8 @@ internal sealed class HrKpiDashboardBuilder FluktuationFilter = NormalizeFilter(options.FluktuationFilter), GlzAmpel = NormalizeFilter(options.GlzAmpel), RestferienAmpel = NormalizeFilter(options.RestferienAmpel), - SearchText = NormalizeFilter(options.SearchText) + SearchText = NormalizeFilter(options.SearchText), + ManagementView = options.ManagementView }; var result = new HrKpiResult { Options = normalizedOptions }; @@ -107,6 +108,23 @@ internal sealed class HrKpiDashboardBuilder result.TurnoverMetrics = BuildTurnoverMetrics(turnoverEmployees, turnoverHeadcountLeavers, leavers, turnoverPeriod); result.AbsenceMetrics = BuildAbsenceMetrics(employees, absences); result.TimeVacationMetrics = BuildTimeVacationMetrics(employees); + result.PeriodComparisonMetrics = BuildPeriodComparisonMetrics(turnoverEmployees, turnoverHeadcountLeavers, leavers, turnoverPeriod); + result.TrafficLights = BuildTrafficLights(result.Metrics, result.TurnoverMetrics, result.AbsenceMetrics, result.TimeVacationMetrics, context); + result.DataQualityIssues = BuildDataQualityIssues(employees, absences, leavers, sapRows, context); + result.LeaversByType = BuildLeaverTypeGroups(leavers); + result.LeaversByOrganisation = BuildLeaverOrganisationGroups(leavers); + result.AbsenceByOrganisation = BuildAbsenceOrganisationGroups(absences); + result.CriticalAbsences = absences + .Where(x => x.KrankheitstageGesamt > 0) + .OrderByDescending(x => x.KrankheitstageGesamt) + .Select(absence => employees.FirstOrDefault(employee => employee.Personalnummer == absence.Personalnummer) ?? new HrKpiEmployeeRow + { + Personalnummer = absence.Personalnummer, + NameVoll = absence.Name, + Organisationseinheit = absence.Organisationseinheit + }) + .Take(25) + .ToList(); result.TurnoverVisuals = BuildTurnoverVisuals(turnoverEmployees, turnoverHeadcountLeavers, leavers, turnoverPeriod); result.HeadcountByOrganisation = employees .GroupBy(x => BlankAsUnknown(x.Organisationseinheit), StringComparer.OrdinalIgnoreCase) @@ -587,6 +605,171 @@ internal sealed class HrKpiDashboardBuilder ]; } + private static List BuildPeriodComparisonMetrics( + IReadOnlyCollection employees, + IReadOnlyCollection turnoverHeadcountLeavers, + IReadOnlyCollection leavers, + TurnoverPeriodScope period) + { + var selectedYear = period.BreakdownYear ?? leavers + .Where(x => x.Austrittsjahr.HasValue) + .Select(x => x.Austrittsjahr!.Value) + .DefaultIfEmpty(DateTime.Today.Year) + .Max(); + var previousYear = selectedYear - 1; + var intervals = BuildTurnoverIntervals(employees, turnoverHeadcountLeavers); + var selectedHeadcount = CalculateAverageFixedHeadcount(intervals, Enumerable.Range(1, 12).Select(month => (selectedYear, month))); + var previousHeadcount = CalculateAverageFixedHeadcount(intervals, Enumerable.Range(1, 12).Select(month => (previousYear, month))); + var selectedLeavers = CountDistinctPersons(leavers + .Where(x => x.IstFluktuationsrelevant && x.Austrittsjahr == selectedYear) + .Select(x => x.Personalnummer)); + var previousLeavers = CountDistinctPersons(leavers + .Where(x => x.IstFluktuationsrelevant && x.Austrittsjahr == previousYear) + .Select(x => x.Personalnummer)); + var selectedRate = selectedHeadcount == 0 ? 0 : selectedLeavers / selectedHeadcount; + var previousRate = previousHeadcount == 0 ? 0 : previousLeavers / previousHeadcount; + var deltaRate = selectedRate - previousRate; + var selectedAbs = leavers.Count(x => x.Austrittsjahr == selectedYear); + var previousAbs = leavers.Count(x => x.Austrittsjahr == previousYear); + + return + [ + new() { Label = $"Headcount {selectedYear}", Value = FormatHeadcount(selectedHeadcount), Detail = $"Vorjahr {FormatHeadcount(previousHeadcount)}", Severity = "Normal" }, + new() { Label = $"Austritte {selectedYear}", Value = selectedAbs.ToString("N0"), Detail = $"Vorjahr {previousAbs:N0}", Severity = selectedAbs > previousAbs ? "Warning" : "Normal" }, + new() { Label = $"Fluktuation {selectedYear}", Value = selectedRate.ToString("P1"), Detail = $"Vorjahr {previousRate:P1}", Severity = selectedRate > 0.12m ? "Warning" : "Normal" }, + new() { Label = "Delta Fluktuation", Value = deltaRate.ToString("+0.0%;-0.0%;0.0%"), Detail = $"{selectedYear} gegen {previousYear}", Severity = deltaRate > 0.02m ? "Warning" : "Normal" } + ]; + } + + private static List BuildTrafficLights( + IReadOnlyList overviewMetrics, + IReadOnlyList turnoverMetrics, + IReadOnlyList absenceMetrics, + IReadOnlyList timeVacationMetrics, + ImportContext context) + { + var turnover = FindMetric(turnoverMetrics, "Fluktuation Jahr Effektiv %") ?? FindMetric(overviewMetrics, "Fluktuation"); + var absence = FindMetric(absenceMetrics, "Krankenquote"); + var glzRed = FindMetric(timeVacationMetrics, "GLZ Rot"); + var vacationRed = FindMetric(timeVacationMetrics, "Restferien Rot"); + var missingFiles = context.FileStatuses.Count(x => !x.Exists); + + return + [ + BuildTrafficLight("Fluktuation", turnover?.Value ?? "-", turnover?.Detail ?? string.Empty, turnover?.Severity == "Warning"), + BuildTrafficLight("Krankenquote", absence?.Value ?? "-", absence?.Detail ?? string.Empty, absence?.Severity == "Warning"), + BuildTrafficLight("GLZ-Saldi", glzRed?.Value ?? "0", glzRed?.Detail ?? string.Empty, ParseInt(glzRed?.Value) > 0), + BuildTrafficLight("Restferien", vacationRed?.Value ?? "0", vacationRed?.Detail ?? string.Empty, ParseInt(vacationRed?.Value) > 0), + new() + { + Area = "Datenqualitaet", + Status = missingFiles == 0 ? "Gruen" : "Rot", + Value = missingFiles.ToString("N0"), + Detail = missingFiles == 0 ? "Alle erwarteten Dateien gefunden" : "Erwartete Dateien fehlen" + } + ]; + } + + private static List BuildDataQualityIssues( + IReadOnlyCollection employees, + IReadOnlyCollection absences, + IReadOnlyCollection leavers, + IReadOnlyDictionary sapRows, + ImportContext context) + { + var employeeNumbers = employees + .Where(x => x.Personalnummer.HasValue) + .Select(x => x.Personalnummer!.Value) + .ToHashSet(); + var duplicateEmployeeNumbers = employees + .Where(x => x.Personalnummer.HasValue) + .GroupBy(x => x.Personalnummer!.Value) + .Count(g => g.Count() > 1); + var sapNumbers = sapRows.Keys + .Select(key => int.TryParse(key, NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed) ? parsed : (int?)null) + .Where(x => x.HasValue) + .Select(x => x!.Value) + .ToHashSet(); + + return new[] + { + CreateQualityIssue("Error", "Dateien", "Fehlende Dateien", context.FileStatuses.Count(x => !x.Exists), "Erwartete HR-KPI-Datei wurde im Datenordner nicht gefunden."), + CreateQualityIssue("Warning", "Mitarbeitende", "Fehlende Personalnummer", employees.Count(x => !x.Personalnummer.HasValue), "Diese Zeilen zaehlen nicht in Distinct-Headcount-Kennzahlen."), + CreateQualityIssue("Warning", "Mitarbeitende", "Doppelte Personalnummer", duplicateEmployeeNumbers, "Mehrere aktive Zeilen mit gleicher Personalnummer."), + CreateQualityIssue("Warning", "Rexx/SAP", "Rexx ohne SAP", employeeNumbers.Count(number => !sapNumbers.Contains(number)), "Aktive Mitarbeitende ohne passende SAP-Zusatzzeile."), + CreateQualityIssue("Info", "Rexx/SAP", "SAP ohne Rexx", sapNumbers.Count(number => !employeeNumbers.Contains(number)), "SAP-Zeile ohne aktive Rexx-Mitarbeiterzeile."), + CreateQualityIssue("Warning", "Mitarbeitende", "Fehlende Organisation", employees.Count(x => string.IsNullOrWhiteSpace(x.Organisationseinheit)), "Organisationseinheit fehlt."), + CreateQualityIssue("Warning", "Mitarbeitende", "Fehlende Kostenstelle", employees.Count(x => string.IsNullOrWhiteSpace(x.KostenstelleText)), "Kostenstelle fehlt."), + CreateQualityIssue("Warning", "Mitarbeitende", "Fehlender Beschaeftigungsgrad", employees.Count(x => !x.BeschaeftigungsgradProzent.HasValue), "FTE verwendet Rexx-Fallback."), + CreateQualityIssue("Info", "Absenzen", "Absenzen ohne aktive Person", absences.Count(x => x.Personalnummer.HasValue && !employeeNumbers.Contains(x.Personalnummer.Value)), "Absenzzeile passt nicht auf aktuell aktive Mitarbeitendenfilter."), + CreateQualityIssue("Info", "Austritte", "Austritte ohne Personalnummer", leavers.Count(x => !x.Personalnummer.HasValue), "Austritt kann nicht eindeutig per Personalnummer gruppiert werden.") + }.Where(x => x.Count > 0).ToList(); + } + + private static List BuildLeaverTypeGroups(IReadOnlyCollection leavers) + => leavers + .GroupBy(x => BlankAsUnknown(string.IsNullOrWhiteSpace(x.AustrittsartNormalisiert) ? x.Austrittsart : x.AustrittsartNormalisiert), StringComparer.OrdinalIgnoreCase) + .Select(g => new HrKpiGroupValue { Label = g.Key, Count = CountDistinctPersons(g.Select(x => x.Personalnummer)), Value = CountDistinctPersons(g.Select(x => x.Personalnummer)) }) + .OrderByDescending(x => x.Count) + .ThenBy(x => x.Label, StringComparer.OrdinalIgnoreCase) + .ToList(); + + private static List BuildLeaverOrganisationGroups(IReadOnlyCollection leavers) + => leavers + .GroupBy(x => BlankAsUnknown(x.Organisationseinheit), StringComparer.OrdinalIgnoreCase) + .Select(g => new HrKpiGroupValue { Label = g.Key, Count = CountDistinctPersons(g.Select(x => x.Personalnummer)), Value = CountDistinctPersons(g.Select(x => x.Personalnummer)) }) + .OrderByDescending(x => x.Count) + .ThenBy(x => x.Label, StringComparer.OrdinalIgnoreCase) + .ToList(); + + private static List BuildAbsenceOrganisationGroups(IReadOnlyCollection absences) + { + var total = absences.Sum(x => x.KrankheitstageGesamt); + return absences + .GroupBy(x => BlankAsUnknown(x.Organisationseinheit), StringComparer.OrdinalIgnoreCase) + .Select(g => + { + var value = g.Sum(x => x.KrankheitstageGesamt); + return new HrKpiGroupValue + { + Label = g.Key, + Count = g.Count(), + Value = value, + Percent = total == 0 ? 0 : value / total * 100m + }; + }) + .OrderByDescending(x => x.Value) + .ThenBy(x => x.Label, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static HrKpiMetric? FindMetric(IEnumerable metrics, string labelPart) + => metrics.FirstOrDefault(x => x.Label.Contains(labelPart, StringComparison.OrdinalIgnoreCase)); + + private static HrKpiTrafficLight BuildTrafficLight(string area, string value, string detail, bool warning) + => new() + { + Area = area, + Status = warning ? "Gelb" : "Gruen", + Value = value, + Detail = detail + }; + + private static HrKpiDataQualityIssue CreateQualityIssue(string severity, string area, string issue, int count, string detail) + => new() + { + Severity = severity, + Area = area, + Issue = issue, + Count = count, + Detail = detail + }; + + private static int ParseInt(string? value) + => int.TryParse((value ?? string.Empty).Replace("'", string.Empty), NumberStyles.Any, CultureInfo.CurrentCulture, out var parsed) + ? parsed + : 0; + private static HrTurnoverVisuals BuildTurnoverVisuals( IReadOnlyCollection employees, IReadOnlyCollection turnoverHeadcountLeavers, @@ -1219,6 +1402,8 @@ internal sealed class HrKpiDashboardBuilder public bool HasFile(string fileName) => File.Exists(BuildPath(fileName)); + public IReadOnlyList FileStatuses => _result.FileStatuses; + public List ReadRows(string fileName, string label, Func, T> map) { var path = BuildPath(fileName); @@ -1228,6 +1413,12 @@ internal sealed class HrKpiDashboardBuilder Path = path, Exists = File.Exists(path) }; + if (status.Exists) + { + status.LastModified = File.GetLastWriteTime(path); + status.AgeDays = Math.Max(0, (DateTime.Today - status.LastModified.Value.Date).Days); + status.FreshnessStatus = status.AgeDays <= 7 ? "Aktuell" : status.AgeDays <= 31 ? "Aelter" : "Alt"; + } _result.FileStatuses.Add(status); if (!status.Exists) diff --git a/TrafagSalesExporter/docs/FINANCE_COCKPIT_ANLEITUNG_FINANZ_2026-05-20.docx b/TrafagSalesExporter/docs/FINANCE_COCKPIT_ANLEITUNG_FINANZ_2026-05-20.docx new file mode 100644 index 0000000..f6b076e Binary files /dev/null and b/TrafagSalesExporter/docs/FINANCE_COCKPIT_ANLEITUNG_FINANZ_2026-05-20.docx differ diff --git a/TrafagSalesExporter/docs/HR_KPI_ANLEITUNG_HR_2026-05-20.docx b/TrafagSalesExporter/docs/HR_KPI_ANLEITUNG_HR_2026-05-20.docx new file mode 100644 index 0000000..428400e Binary files /dev/null and b/TrafagSalesExporter/docs/HR_KPI_ANLEITUNG_HR_2026-05-20.docx differ