Expand HR KPI cockpit and add user guides

This commit is contained in:
2026-05-20 15:27:03 +02:00
parent 610e771b9b
commit 06fb56075f
6 changed files with 384 additions and 7 deletions
@@ -7,6 +7,15 @@
<MudTabPanel Text="@T("Ueberblick", "Overview")" Icon="@Icons.Material.Filled.Dashboard"> <MudTabPanel Text="@T("Ueberblick", "Overview")" Icon="@Icons.Material.Filled.Dashboard">
@MetricGrid(Result.Metrics) @MetricGrid(Result.Metrics)
<MudGrid Class="mt-4">
<MudItem xs="12" md="7">
@TrafficLightPanel(Result.TrafficLights)
</MudItem>
<MudItem xs="12" md="5">
@MetricGrid(Result.PeriodComparisonMetrics)
</MudItem>
</MudGrid>
<MudGrid Class="mt-4"> <MudGrid Class="mt-4">
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
@HeadcountByOrganisationTable(Result.HeadcountByOrganisation) @HeadcountByOrganisationTable(Result.HeadcountByOrganisation)
@@ -29,6 +38,15 @@
</MudItem> </MudItem>
</MudGrid> </MudGrid>
<MudGrid Class="mt-4">
<MudItem xs="12" md="6">
@GroupValueTable(T("Austritte nach Austrittsart", "Leavers by exit type"), Result.LeaversByType, T("Austritte", "Leavers"))
</MudItem>
<MudItem xs="12" md="6">
@GroupValueTable(T("Austritte nach Organisation", "Leavers by organisation"), Result.LeaversByOrganisation, T("Austritte", "Leavers"))
</MudItem>
</MudGrid>
<MudGrid Class="mt-4"> <MudGrid Class="mt-4">
<MudItem xs="12" md="4"> <MudItem xs="12" md="4">
@TurnoverGauge(Result.TurnoverVisuals) @TurnoverGauge(Result.TurnoverVisuals)
@@ -51,8 +69,21 @@
</MudGrid> </MudGrid>
</MudTabPanel> </MudTabPanel>
<MudTabPanel Text="@T("Ampel", "Status")" Icon="@Icons.Material.Filled.Traffic">
@TrafficLightPanel(Result.TrafficLights)
@MetricGrid(Result.PeriodComparisonMetrics)
</MudTabPanel>
<MudTabPanel Text="@T("Absenzen", "Absences")" Icon="@Icons.Material.Filled.Sick"> <MudTabPanel Text="@T("Absenzen", "Absences")" Icon="@Icons.Material.Filled.Sick">
@MetricGrid(Result.AbsenceMetrics) @MetricGrid(Result.AbsenceMetrics)
<MudGrid Class="mt-4">
<MudItem xs="12" md="6">
@GroupValueTable(T("Absenzen nach Organisation", "Absences by organisation"), Result.AbsenceByOrganisation, T("Krankheitstage", "Sick days"))
</MudItem>
<MudItem xs="12" md="6">
@TopAbsencesTable(Result.Absences)
</MudItem>
</MudGrid>
<MudPaper Class="pa-4 mt-4" Elevation="1"> <MudPaper Class="pa-4 mt-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Absenzen je Mitarbeiter", "Absences by employee")</MudText> <MudText Typo="Typo.h6" Class="mb-2">@T("Absenzen je Mitarbeiter", "Absences by employee")</MudText>
<MudTable Items="Result.Absences.OrderByDescending(x => x.KrankheitstageGesamt).Take(100)" Dense Hover Striped> <MudTable Items="Result.Absences.OrderByDescending(x => x.KrankheitstageGesamt).Take(100)" Dense Hover Striped>
@@ -67,7 +98,7 @@
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd>@context.Personalnummer</MudTd> <MudTd>@context.Personalnummer</MudTd>
<MudTd>@context.Name</MudTd> <MudTd>@DisplayPersonName(context.Name, context.Personalnummer, Result.Options.ManagementView)</MudTd>
<MudTd>@context.Organisationseinheit</MudTd> <MudTd>@context.Organisationseinheit</MudTd>
<MudTd>@context.KrankheitstageKurz.ToString("N1")</MudTd> <MudTd>@context.KrankheitstageKurz.ToString("N1")</MudTd>
<MudTd>@context.KrankheitstageLang.ToString("N1")</MudTd> <MudTd>@context.KrankheitstageLang.ToString("N1")</MudTd>
@@ -100,7 +131,7 @@
<MudTh>@T("Ampel", "Status")</MudTh> <MudTh>@T("Ampel", "Status")</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd>@context.NameVoll</MudTd> <MudTd>@DisplayPersonName(context.NameVoll, context.Personalnummer, Result.Options.ManagementView)</MudTd>
<MudTd>@context.Organisationseinheit</MudTd> <MudTd>@context.Organisationseinheit</MudTd>
<MudTd>@context.UrlaubRest.ToString("N1")</MudTd> <MudTd>@context.UrlaubRest.ToString("N1")</MudTd>
<MudTd>@context.FerienAusstehend.ToString("N1")</MudTd> <MudTd>@context.FerienAusstehend.ToString("N1")</MudTd>
@@ -122,6 +153,11 @@
<MudTabPanel Text="@T("Datenstatus", "Data status")" Icon="@Icons.Material.Filled.FactCheck"> <MudTabPanel Text="@T("Datenstatus", "Data status")" Icon="@Icons.Material.Filled.FactCheck">
@FileStatusTable(Result.FileStatuses) @FileStatusTable(Result.FileStatuses)
<MudGrid Class="mt-4">
<MudItem xs="12">
@DataQualityTable(Result.DataQualityIssues)
</MudItem>
</MudGrid>
</MudTabPanel> </MudTabPanel>
<MudTabPanel Text="@T("Anleitung", "Guide")" Icon="@Icons.Material.Filled.HelpOutline"> <MudTabPanel Text="@T("Anleitung", "Guide")" Icon="@Icons.Material.Filled.HelpOutline">
@@ -145,6 +181,19 @@
_ => Color.Success _ => 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) private static string FormatDate(DateTime? value)
=> value?.ToString("dd.MM.yyyy") ?? "-"; => value?.ToString("dd.MM.yyyy") ?? "-";
@@ -177,6 +226,93 @@
</MudTable> </MudTable>
</MudPaper>; </MudPaper>;
private RenderFragment<IReadOnlyList<HrKpiTrafficLight>> TrafficLightPanel => items => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("HR-Ampel", "HR status")</MudText>
<MudGrid>
@foreach (var item in items)
{
<MudItem xs="12" sm="6" md="4">
<MudPaper Class="pa-3" Elevation="0">
<MudStack Row AlignItems="AlignItems.Center" Spacing="2">
<MudChip T="string" Size="Size.Small" Color="@TrafficLightColor(item.Status)" Variant="Variant.Filled">
@item.Status
</MudChip>
<MudText Typo="Typo.subtitle2">@item.Area</MudText>
</MudStack>
<MudText Typo="Typo.h6">@item.Value</MudText>
<MudText Typo="Typo.body2">@item.Detail</MudText>
</MudPaper>
</MudItem>
}
</MudGrid>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrKpiDataQualityIssue>> DataQualityTable => items => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Datenqualitaet", "Data quality")</MudText>
<MudTable Items="items" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Schwere", "Severity")</MudTh>
<MudTh>@T("Bereich", "Area")</MudTh>
<MudTh>@T("Pruefpunkt", "Check")</MudTh>
<MudTh>@T("Anzahl", "Count")</MudTh>
<MudTh>@T("Hinweis", "Note")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudChip T="string" Size="Size.Small" Color="@MapQualityColor(context.Severity)" Variant="Variant.Outlined">
@context.Severity
</MudChip>
</MudTd>
<MudTd>@context.Area</MudTd>
<MudTd>@context.Issue</MudTd>
<MudTd>@context.Count.ToString("N0")</MudTd>
<MudTd>@context.Detail</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.body2">@T("Keine Datenqualitaetswarnungen.", "No data quality warnings.")</MudText>
</NoRecordsContent>
</MudTable>
</MudPaper>;
private RenderFragment<(string Title, IReadOnlyList<HrKpiGroupValue> Items, string ValueLabel)> GroupValueTableTuple => data => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@data.Title</MudText>
<MudTable Items="data.Items" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Gruppe", "Group")</MudTh>
<MudTh>@data.ValueLabel</MudTh>
<MudTh>%</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd>@(context.Value != 0 ? context.Value.ToString("N1") : context.Count.ToString("N0"))</MudTd>
<MudTd>@context.Percent.ToString("N1")</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>;
private RenderFragment GroupValueTable(string title, IReadOnlyList<HrKpiGroupValue> items, string valueLabel)
=> GroupValueTableTuple((title, items, valueLabel));
private RenderFragment<IReadOnlyList<HrAbsenceRow>> TopAbsencesTable => items => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Hoechste Absenzen", "Highest absences")</MudText>
<MudTable Items="items.OrderByDescending(x => x.KrankheitstageGesamt).Take(25)" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Name", "Name")</MudTh>
<MudTh>@T("Organisation", "Organisation")</MudTh>
<MudTh>@T("Kurz", "Short")</MudTh>
<MudTh>@T("Lang", "Long")</MudTh>
<MudTh>@T("Gesamt", "Total")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@DisplayPersonName(context.Name, context.Personalnummer, Result.Options.ManagementView)</MudTd>
<MudTd>@context.Organisationseinheit</MudTd>
<MudTd>@context.KrankheitstageKurz.ToString("N1")</MudTd>
<MudTd>@context.KrankheitstageLang.ToString("N1")</MudTd>
<MudTd>@context.KrankheitstageGesamt.ToString("N1")</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>;
private RenderFragment<IReadOnlyList<HrKpiEmployeeRow>> CriticalBalancesTable => items => @<MudPaper Class="pa-4" Elevation="1"> private RenderFragment<IReadOnlyList<HrKpiEmployeeRow>> CriticalBalancesTable => items => @<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Kritische GLZ-Saldi", "Critical time balances")</MudText> <MudText Typo="Typo.h6" Class="mb-2">@T("Kritische GLZ-Saldi", "Critical time balances")</MudText>
<MudTable Items="items" Dense Hover Striped> <MudTable Items="items" Dense Hover Striped>
@@ -187,7 +323,7 @@
<MudTh>@T("Ampel", "Status")</MudTh> <MudTh>@T("Ampel", "Status")</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd>@context.NameVoll</MudTd> <MudTd>@DisplayPersonName(context.NameVoll, context.Personalnummer, Result.Options.ManagementView)</MudTd>
<MudTd>@context.Organisationseinheit</MudTd> <MudTd>@context.Organisationseinheit</MudTd>
<MudTd>@context.StundenSaldo.ToString("N1")</MudTd> <MudTd>@context.StundenSaldo.ToString("N1")</MudTd>
<MudTd> <MudTd>
@@ -209,7 +345,7 @@
<MudTh>@T("Austrittsart", "Exit type")</MudTh> <MudTh>@T("Austrittsart", "Exit type")</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd>@context.NameVoll</MudTd> <MudTd>@DisplayPersonName(context.NameVoll, context.Personalnummer, Result.Options.ManagementView)</MudTd>
<MudTd>@FormatDate(context.Austrittsdatum)</MudTd> <MudTd>@FormatDate(context.Austrittsdatum)</MudTd>
<MudTd>@context.Organisationseinheit</MudTd> <MudTd>@context.Organisationseinheit</MudTd>
<MudTd>@context.Austrittsart</MudTd> <MudTd>@context.Austrittsart</MudTd>
@@ -246,7 +382,7 @@
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd>@context.Personalnummer</MudTd> <MudTd>@context.Personalnummer</MudTd>
<MudTd>@context.NameVoll</MudTd> <MudTd>@DisplayPersonName(context.NameVoll, context.Personalnummer, Result.Options.ManagementView)</MudTd>
<MudTd>@context.Organisationseinheit</MudTd> <MudTd>@context.Organisationseinheit</MudTd>
<MudTd>@context.KostenstelleText</MudTd> <MudTd>@context.KostenstelleText</MudTd>
<MudTd>@context.Fte.ToString("N2")</MudTd> <MudTd>@context.Fte.ToString("N2")</MudTd>
@@ -266,6 +402,8 @@
<HeaderContent> <HeaderContent>
<MudTh>@T("Quelle", "Source")</MudTh> <MudTh>@T("Quelle", "Source")</MudTh>
<MudTh>@T("Status", "Status")</MudTh> <MudTh>@T("Status", "Status")</MudTh>
<MudTh>@T("Stand", "Modified")</MudTh>
<MudTh>@T("Alter", "Age")</MudTh>
<MudTh>@T("Zeilen", "Rows")</MudTh> <MudTh>@T("Zeilen", "Rows")</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
@@ -278,6 +416,8 @@
@(context.Message ?? "-") @(context.Message ?? "-")
</MudChip> </MudChip>
</MudTd> </MudTd>
<MudTd>@FormatDate(context.LastModified)</MudTd>
<MudTd>@(context.AgeDays.HasValue ? $"{context.AgeDays:N0} Tage / {context.FreshnessStatus}" : "-")</MudTd>
<MudTd>@context.RowCount.ToString("N0")</MudTd> <MudTd>@context.RowCount.ToString("N0")</MudTd>
</RowTemplate> </RowTemplate>
</MudTable> </MudTable>
@@ -7,6 +7,7 @@
@inject IHrKpiAccessService HrKpiAccess @inject IHrKpiAccessService HrKpiAccess
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject IUiTextService UiText @inject IUiTextService UiText
@inject IJSRuntime JsRuntime
<PageTitle>@T("HR KPI", "HR KPI")</PageTitle> <PageTitle>@T("HR KPI", "HR KPI")</PageTitle>
@@ -65,6 +66,10 @@ else
@(_loading ? T("Lade...", "Loading...") : T("Laden", "Load")) @(_loading ? T("Lade...", "Loading...") : T("Laden", "Load"))
</MudButton> </MudButton>
</MudItem> </MudItem>
<MudItem xs="12" md="2">
<MudSwitch T="bool" @bind-Value="_managementView" Color="Color.Primary"
Label="@T("Managementsicht", "Management view")" />
</MudItem>
<MudItem xs="12" md="3"> <MudItem xs="12" md="3">
<MudDatePicker @bind-Date="_fromDate" Label="@T("Von Austritt", "Exit from")" Clearable DateFormat="dd.MM.yyyy" /> <MudDatePicker @bind-Date="_fromDate" Label="@T("Von Austritt", "Exit from")" Clearable DateFormat="dd.MM.yyyy" />
</MudItem> </MudItem>
@@ -128,6 +133,12 @@ else
@T("Sperren", "Lock") @T("Sperren", "Lock")
</MudButton> </MudButton>
</MudItem> </MudItem>
<MudItem xs="12" md="2">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="PrintAsync"
StartIcon="@Icons.Material.Filled.Print" FullWidth>
@T("Drucken/PDF", "Print/PDF")
</MudButton>
</MudItem>
</MudGrid> </MudGrid>
</MudPaper> </MudPaper>
} }
@@ -161,6 +172,7 @@ else
private string? _glzAmpel; private string? _glzAmpel;
private string? _restferienAmpel; private string? _restferienAmpel;
private string? _searchText; private string? _searchText;
private bool _managementView;
private string? _hrUsername; private string? _hrUsername;
private string? _hrPassword; private string? _hrPassword;
private bool _loading; private bool _loading;
@@ -207,7 +219,8 @@ else
FluktuationFilter = _fluktuationFilter, FluktuationFilter = _fluktuationFilter,
GlzAmpel = _glzAmpel, GlzAmpel = _glzAmpel,
RestferienAmpel = _restferienAmpel, RestferienAmpel = _restferienAmpel,
SearchText = _searchText SearchText = _searchText,
ManagementView = _managementView
}); });
} }
catch (Exception ex) catch (Exception ex)
@@ -239,6 +252,11 @@ else
_hrPassword = string.Empty; _hrPassword = string.Empty;
} }
private async Task PrintAsync()
{
await JsRuntime.InvokeVoidAsync("print");
}
private bool CanShowHrKpi => !HrKpiAccess.IsEnabled || HrKpiAccess.IsUnlocked; private bool CanShowHrKpi => !HrKpiAccess.IsEnabled || HrKpiAccess.IsUnlocked;
private string T(string german, string english) => UiText.Text(german, english); private string T(string german, string english) => UiText.Text(german, english);
+28
View File
@@ -14,6 +14,7 @@ public sealed class HrKpiOptions
public string? GlzAmpel { get; set; } public string? GlzAmpel { get; set; }
public string? RestferienAmpel { get; set; } public string? RestferienAmpel { get; set; }
public string? SearchText { get; set; } public string? SearchText { get; set; }
public bool ManagementView { get; set; }
} }
public sealed class HrKpiDataSourceOptions public sealed class HrKpiDataSourceOptions
@@ -57,6 +58,13 @@ public sealed class HrKpiResult
public List<HrKpiMetric> TurnoverMetrics { get; set; } = []; public List<HrKpiMetric> TurnoverMetrics { get; set; } = [];
public List<HrKpiMetric> AbsenceMetrics { get; set; } = []; public List<HrKpiMetric> AbsenceMetrics { get; set; } = [];
public List<HrKpiMetric> TimeVacationMetrics { get; set; } = []; public List<HrKpiMetric> TimeVacationMetrics { get; set; } = [];
public List<HrKpiMetric> PeriodComparisonMetrics { get; set; } = [];
public List<HrKpiTrafficLight> TrafficLights { get; set; } = [];
public List<HrKpiDataQualityIssue> DataQualityIssues { get; set; } = [];
public List<HrKpiGroupValue> LeaversByType { get; set; } = [];
public List<HrKpiGroupValue> LeaversByOrganisation { get; set; } = [];
public List<HrKpiGroupValue> AbsenceByOrganisation { get; set; } = [];
public List<HrKpiEmployeeRow> CriticalAbsences { get; set; } = [];
public List<HrKpiEmployeeRow> Employees { get; set; } = []; public List<HrKpiEmployeeRow> Employees { get; set; } = [];
public List<HrAbsenceRow> Absences { get; set; } = []; public List<HrAbsenceRow> Absences { get; set; } = [];
public List<HrLeaverRow> Leavers { get; set; } = []; public List<HrLeaverRow> Leavers { get; set; } = [];
@@ -73,6 +81,26 @@ public sealed class HrKpiFileStatus
public bool Exists { get; set; } public bool Exists { get; set; }
public int RowCount { get; set; } public int RowCount { get; set; }
public string? Message { 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 public sealed class HrKpiMetric
@@ -38,7 +38,8 @@ internal sealed class HrKpiDashboardBuilder
FluktuationFilter = NormalizeFilter(options.FluktuationFilter), FluktuationFilter = NormalizeFilter(options.FluktuationFilter),
GlzAmpel = NormalizeFilter(options.GlzAmpel), GlzAmpel = NormalizeFilter(options.GlzAmpel),
RestferienAmpel = NormalizeFilter(options.RestferienAmpel), RestferienAmpel = NormalizeFilter(options.RestferienAmpel),
SearchText = NormalizeFilter(options.SearchText) SearchText = NormalizeFilter(options.SearchText),
ManagementView = options.ManagementView
}; };
var result = new HrKpiResult { Options = normalizedOptions }; var result = new HrKpiResult { Options = normalizedOptions };
@@ -107,6 +108,23 @@ internal sealed class HrKpiDashboardBuilder
result.TurnoverMetrics = BuildTurnoverMetrics(turnoverEmployees, turnoverHeadcountLeavers, leavers, turnoverPeriod); result.TurnoverMetrics = BuildTurnoverMetrics(turnoverEmployees, turnoverHeadcountLeavers, leavers, turnoverPeriod);
result.AbsenceMetrics = BuildAbsenceMetrics(employees, absences); result.AbsenceMetrics = BuildAbsenceMetrics(employees, absences);
result.TimeVacationMetrics = BuildTimeVacationMetrics(employees); 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.TurnoverVisuals = BuildTurnoverVisuals(turnoverEmployees, turnoverHeadcountLeavers, leavers, turnoverPeriod);
result.HeadcountByOrganisation = employees result.HeadcountByOrganisation = employees
.GroupBy(x => BlankAsUnknown(x.Organisationseinheit), StringComparer.OrdinalIgnoreCase) .GroupBy(x => BlankAsUnknown(x.Organisationseinheit), StringComparer.OrdinalIgnoreCase)
@@ -587,6 +605,171 @@ internal sealed class HrKpiDashboardBuilder
]; ];
} }
private static List<HrKpiMetric> BuildPeriodComparisonMetrics(
IReadOnlyCollection<HrKpiEmployeeRow> employees,
IReadOnlyCollection<HrLeaverRow> turnoverHeadcountLeavers,
IReadOnlyCollection<HrLeaverRow> 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<HrKpiTrafficLight> BuildTrafficLights(
IReadOnlyList<HrKpiMetric> overviewMetrics,
IReadOnlyList<HrKpiMetric> turnoverMetrics,
IReadOnlyList<HrKpiMetric> absenceMetrics,
IReadOnlyList<HrKpiMetric> 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<HrKpiDataQualityIssue> BuildDataQualityIssues(
IReadOnlyCollection<HrKpiEmployeeRow> employees,
IReadOnlyCollection<HrAbsenceRow> absences,
IReadOnlyCollection<HrLeaverRow> leavers,
IReadOnlyDictionary<string, SapRow> 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<HrKpiGroupValue> BuildLeaverTypeGroups(IReadOnlyCollection<HrLeaverRow> 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<HrKpiGroupValue> BuildLeaverOrganisationGroups(IReadOnlyCollection<HrLeaverRow> 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<HrKpiGroupValue> BuildAbsenceOrganisationGroups(IReadOnlyCollection<HrAbsenceRow> 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<HrKpiMetric> 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( private static HrTurnoverVisuals BuildTurnoverVisuals(
IReadOnlyCollection<HrKpiEmployeeRow> employees, IReadOnlyCollection<HrKpiEmployeeRow> employees,
IReadOnlyCollection<HrLeaverRow> turnoverHeadcountLeavers, IReadOnlyCollection<HrLeaverRow> turnoverHeadcountLeavers,
@@ -1219,6 +1402,8 @@ internal sealed class HrKpiDashboardBuilder
public bool HasFile(string fileName) public bool HasFile(string fileName)
=> File.Exists(BuildPath(fileName)); => File.Exists(BuildPath(fileName));
public IReadOnlyList<HrKpiFileStatus> FileStatuses => _result.FileStatuses;
public List<T> ReadRows<T>(string fileName, string label, Func<IXLRow, IReadOnlyDictionary<string, int>, T> map) public List<T> ReadRows<T>(string fileName, string label, Func<IXLRow, IReadOnlyDictionary<string, int>, T> map)
{ {
var path = BuildPath(fileName); var path = BuildPath(fileName);
@@ -1228,6 +1413,12 @@ internal sealed class HrKpiDashboardBuilder
Path = path, Path = path,
Exists = File.Exists(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); _result.FileStatuses.Add(status);
if (!status.Exists) if (!status.Exists)