Add HR KPI cockpit
This commit is contained in:
@@ -18,6 +18,9 @@
|
|||||||
<MudNavLink Href="/management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Analytics">
|
<MudNavLink Href="/management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Analytics">
|
||||||
@T("Management Cockpit", "Management Cockpit")
|
@T("Management Cockpit", "Management Cockpit")
|
||||||
</MudNavLink>
|
</MudNavLink>
|
||||||
|
<MudNavLink Href="/hr-kpi" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Groups">
|
||||||
|
@T("HR KPI", "HR KPI")
|
||||||
|
</MudNavLink>
|
||||||
<AuthorizeView Policy="@SecurityPolicies.AdminOnly">
|
<AuthorizeView Policy="@SecurityPolicies.AdminOnly">
|
||||||
<Authorized>
|
<Authorized>
|
||||||
<MudNavLink Href="/settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Settings">
|
<MudNavLink Href="/settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Settings">
|
||||||
|
|||||||
@@ -0,0 +1,746 @@
|
|||||||
|
@page "/hr-kpi"
|
||||||
|
@using TrafagSalesExporter.Services
|
||||||
|
@inject IHrKpiService HrKpiService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IUiTextService UiText
|
||||||
|
|
||||||
|
<PageTitle>@T("HR KPI", "HR KPI")</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-4">@T("HR KPI", "HR KPI")</MudText>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="5">
|
||||||
|
<MudTextField @bind-Value="_dataFolder" Label="@T("Datenordner", "Data folder")" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6" md="2">
|
||||||
|
<MudNumericField T="int" @bind-Value="_year" Label="@T("Austrittsjahr", "Leaver year")" Min="2000" Max="2100" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="3">
|
||||||
|
<MudSelect T="string" @bind-Value="_organisation" Label="@T("Organisation", "Organisation")" Dense Clearable>
|
||||||
|
@foreach (var option in _result?.OrganisationOptions ?? [])
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@option">@option</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="LoadAsync"
|
||||||
|
StartIcon="@Icons.Material.Filled.Refresh" Disabled="_loading" FullWidth>
|
||||||
|
@(_loading ? T("Lade...", "Loading...") : T("Laden", "Load"))
|
||||||
|
</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="3">
|
||||||
|
<MudDatePicker @bind-Date="_fromDate" Label="@T("Von Austritt", "Exit from")" Clearable DateFormat="dd.MM.yyyy" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="3">
|
||||||
|
<MudDatePicker @bind-Date="_toDate" Label="@T("Bis Austritt", "Exit to")" Clearable DateFormat="dd.MM.yyyy" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2">
|
||||||
|
<MudSelect T="int?" @bind-Value="_entryYear" Label="@T("Eintrittsjahr", "Entry year")" Dense Clearable>
|
||||||
|
@foreach (var option in _result?.EntryYearOptions ?? [])
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@((int?)option)">@option</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudTextField @bind-Value="_searchText" Label="@T("Suche Name / Personalnr.", "Search name / personnel no.")" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="3">
|
||||||
|
<MudSelect T="string" @bind-Value="_kostenstelle" Label="@T("Kostenstelle", "Cost center")" Dense Clearable>
|
||||||
|
@foreach (var option in _result?.KostenstelleOptions ?? [])
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@option">@option</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="3">
|
||||||
|
<MudSelect T="string" @bind-Value="_mitarbeitertyp" Label="@T("Mitarbeitertyp", "Employee type")" Dense Clearable>
|
||||||
|
@foreach (var option in _result?.MitarbeitertypOptions ?? [])
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@option">@option</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2">
|
||||||
|
<MudSelect T="string" @bind-Value="_fluktuationFilter" Label="@T("Fluktuation", "Turnover")" Dense>
|
||||||
|
@foreach (var option in _fluktuationOptions)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@option.Key">@option.Label</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6" md="2">
|
||||||
|
<MudSelect T="string" @bind-Value="_glzAmpel" Label="@T("GLZ", "Time")" Dense Clearable>
|
||||||
|
@foreach (var option in _ampelOptions)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@option">@option</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6" md="2">
|
||||||
|
<MudSelect T="string" @bind-Value="_restferienAmpel" Label="@T("Restferien", "Vacation")" Dense Clearable>
|
||||||
|
@foreach (var option in _restferienOptions)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@option">@option</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@if (_result is not null)
|
||||||
|
{
|
||||||
|
@if (_result.Notices.Count > 0)
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
||||||
|
@foreach (var notice in _result.Notices)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Warning" Dense Variant="Variant.Outlined" Class="mb-2">@notice</MudAlert>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudTabs Elevation="1" Rounded="false" PanelClass="pt-4">
|
||||||
|
<MudTabPanel Text="@T("Ueberblick", "Overview")" Icon="@Icons.Material.Filled.Dashboard">
|
||||||
|
@MetricGrid(_result.Metrics)
|
||||||
|
|
||||||
|
<MudGrid Class="mt-4">
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
@HeadcountByOrganisationTable(_result.HeadcountByOrganisation)
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
@CriticalBalancesTable(_result.CriticalTimeBalances)
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudTabPanel>
|
||||||
|
|
||||||
|
<MudTabPanel Text="@T("Fluktuation", "Turnover")" Icon="@Icons.Material.Filled.TrendingDown">
|
||||||
|
@MetricGrid(_result.TurnoverMetrics)
|
||||||
|
|
||||||
|
<MudGrid Class="mt-4">
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
@TurnoverRelevantTable(_result.FluctuationRelevantLeavers)
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
@LeaverExclusionTable(_result.Leavers)
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<MudGrid Class="mt-4">
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
@TurnoverGauge(_result.TurnoverVisuals)
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
@TurnoverFunnel(_result.TurnoverVisuals.FunnelSteps)
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
@TurnoverDonut(_result.TurnoverVisuals.ExclusionReasons)
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<MudGrid Class="mt-4">
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
@HorizontalBars(_result.TurnoverVisuals.RelevantByOrganisation)
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
@MonthlyBars(_result.TurnoverVisuals.MonthlyRelevantLeavers)
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudTabPanel>
|
||||||
|
|
||||||
|
<MudTabPanel Text="@T("Absenzen", "Absences")" Icon="@Icons.Material.Filled.Sick">
|
||||||
|
@MetricGrid(_result.AbsenceMetrics)
|
||||||
|
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Absenzen je Mitarbeiter", "Absences by employee")</MudText>
|
||||||
|
<MudTable Items="_result.Absences.OrderByDescending(x => x.KrankheitstageGesamt).Take(100)" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Personalnr.", "Personnel no.")</MudTh>
|
||||||
|
<MudTh>@T("Name", "Name")</MudTh>
|
||||||
|
<MudTh>@T("Organisation", "Organisation")</MudTh>
|
||||||
|
<MudTh>@T("Kurz", "Short")</MudTh>
|
||||||
|
<MudTh>@T("Lang", "Long")</MudTh>
|
||||||
|
<MudTh>@T("Gesamt", "Total")</MudTh>
|
||||||
|
<MudTh>@T("Quote", "Rate")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Personalnummer</MudTd>
|
||||||
|
<MudTd>@context.Name</MudTd>
|
||||||
|
<MudTd>@context.Organisationseinheit</MudTd>
|
||||||
|
<MudTd>@context.KrankheitstageKurz.ToString("N1")</MudTd>
|
||||||
|
<MudTd>@context.KrankheitstageLang.ToString("N1")</MudTd>
|
||||||
|
<MudTd>@context.KrankheitstageGesamt.ToString("N1")</MudTd>
|
||||||
|
<MudTd>@context.KrankenquoteMa.ToString("P1")</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
<PagerContent>
|
||||||
|
<MudTablePager />
|
||||||
|
</PagerContent>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>
|
||||||
|
</MudTabPanel>
|
||||||
|
|
||||||
|
<MudTabPanel Text="@T("Zeit / Ferien", "Time / Vacation")" Icon="@Icons.Material.Filled.EventAvailable">
|
||||||
|
@MetricGrid(_result.TimeVacationMetrics)
|
||||||
|
|
||||||
|
<MudGrid Class="mt-4">
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
@CriticalBalancesTable(_result.CriticalTimeBalances)
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Kritische Restferien", "Critical vacation balance")</MudText>
|
||||||
|
<MudTable Items="_result.Employees.OrderByDescending(x => x.UrlaubRest).Take(25)" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Name", "Name")</MudTh>
|
||||||
|
<MudTh>@T("Organisation", "Organisation")</MudTh>
|
||||||
|
<MudTh>@T("Rest", "Left")</MudTh>
|
||||||
|
<MudTh>@T("Ausstehend", "Open")</MudTh>
|
||||||
|
<MudTh>@T("Ampel", "Status")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.NameVoll</MudTd>
|
||||||
|
<MudTd>@context.Organisationseinheit</MudTd>
|
||||||
|
<MudTd>@context.UrlaubRest.ToString("N1")</MudTd>
|
||||||
|
<MudTd>@context.FerienAusstehend.ToString("N1")</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="@TrafficLightColor(context.RestferienAmpel)" Variant="Variant.Outlined">
|
||||||
|
@context.RestferienAmpel
|
||||||
|
</MudChip>
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudTabPanel>
|
||||||
|
|
||||||
|
<MudTabPanel Text="@T("Mitarbeitende", "Employees")" Icon="@Icons.Material.Filled.Groups">
|
||||||
|
@EmployeesTable(_result.Employees)
|
||||||
|
</MudTabPanel>
|
||||||
|
|
||||||
|
<MudTabPanel Text="@T("Datenstatus", "Data status")" Icon="@Icons.Material.Filled.FactCheck">
|
||||||
|
@FileStatusTable(_result.FileStatuses)
|
||||||
|
</MudTabPanel>
|
||||||
|
</MudTabs>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string _dataFolder = @"C:\temp";
|
||||||
|
private int _year = DateTime.Today.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 _loading;
|
||||||
|
private HrKpiResult? _result;
|
||||||
|
private readonly List<(string Key, string Label)> _fluktuationOptions =
|
||||||
|
[
|
||||||
|
("Alle", "Alle"),
|
||||||
|
("Fluktuationsrelevant", "Relevant"),
|
||||||
|
("Arbeitnehmerkuendigung", "Arbeitnehmerkuendigung"),
|
||||||
|
("Ausgeschlossen", "Ausgeschlossen")
|
||||||
|
];
|
||||||
|
private readonly List<string> _ampelOptions = ["Gruen", "Gelb", "Rot"];
|
||||||
|
private readonly List<string> _restferienOptions = ["Gruen", "Rot"];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
_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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add(ex.Message, Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
|
||||||
|
private static Color MetricColor(string severity)
|
||||||
|
=> severity == "Warning" ? Color.Warning : Color.Default;
|
||||||
|
|
||||||
|
private static Color TrafficLightColor(string value)
|
||||||
|
=> value switch
|
||||||
|
{
|
||||||
|
"Rot" => Color.Error,
|
||||||
|
"Gelb" => Color.Warning,
|
||||||
|
_ => Color.Success
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string FormatDate(DateTime? value)
|
||||||
|
=> value?.ToString("dd.MM.yyyy") ?? "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private RenderFragment<IReadOnlyList<HrKpiMetric>> MetricGrid => metrics => @<MudGrid Class="mb-4">
|
||||||
|
@foreach (var metric in metrics)
|
||||||
|
{
|
||||||
|
<MudItem xs="12" sm="6" md="3" lg="2">
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.caption">@metric.Label</MudText>
|
||||||
|
<MudText Typo="Typo.h5">@metric.Value</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Color="@MetricColor(metric.Severity)">@metric.Detail</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
}
|
||||||
|
</MudGrid>;
|
||||||
|
|
||||||
|
private RenderFragment<IReadOnlyList<HrKpiGroupValue>> HeadcountByOrganisationTable => items => @<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Headcount nach Organisation", "Headcount by organisation")</MudText>
|
||||||
|
<MudTable Items="items" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Organisation", "Organisation")</MudTh>
|
||||||
|
<MudTh>@T("Headcount", "Headcount")</MudTh>
|
||||||
|
<MudTh>FTE</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Label</MudTd>
|
||||||
|
<MudTd>@context.Count.ToString("N0")</MudTd>
|
||||||
|
<MudTd>@context.Value.ToString("N1")</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>;
|
||||||
|
|
||||||
|
private RenderFragment<IReadOnlyList<HrKpiEmployeeRow>> CriticalBalancesTable => items => @<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Kritische GLZ-Saldi", "Critical time balances")</MudText>
|
||||||
|
<MudTable Items="items" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Name", "Name")</MudTh>
|
||||||
|
<MudTh>@T("Organisation", "Organisation")</MudTh>
|
||||||
|
<MudTh>@T("Saldo", "Balance")</MudTh>
|
||||||
|
<MudTh>@T("Ampel", "Status")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.NameVoll</MudTd>
|
||||||
|
<MudTd>@context.Organisationseinheit</MudTd>
|
||||||
|
<MudTd>@context.StundenSaldo.ToString("N1")</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="@TrafficLightColor(context.GlzAmpel)" Variant="Variant.Outlined">
|
||||||
|
@context.GlzAmpel
|
||||||
|
</MudChip>
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>;
|
||||||
|
|
||||||
|
private RenderFragment<IReadOnlyList<HrLeaverRow>> TurnoverRelevantTable => items => @<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Fluktuationsrelevante Austritte", "Turnover relevant leavers")</MudText>
|
||||||
|
<MudTable Items="items" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Name", "Name")</MudTh>
|
||||||
|
<MudTh>@T("Austritt", "Exit")</MudTh>
|
||||||
|
<MudTh>@T("Organisation", "Organisation")</MudTh>
|
||||||
|
<MudTh>@T("Austrittsart", "Exit type")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.NameVoll</MudTd>
|
||||||
|
<MudTd>@FormatDate(context.Austrittsdatum)</MudTd>
|
||||||
|
<MudTd>@context.Organisationseinheit</MudTd>
|
||||||
|
<MudTd>@context.Austrittsart</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>;
|
||||||
|
|
||||||
|
private RenderFragment<IReadOnlyList<HrLeaverRow>> LeaverExclusionTable => items => @<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Ausschlussgruende", "Exclusion reasons")</MudText>
|
||||||
|
<MudTable Items="BuildLeaverExclusionRows(items)" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Grund", "Reason")</MudTh>
|
||||||
|
<MudTh>@T("Anzahl", "Count")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Label</MudTd>
|
||||||
|
<MudTd>@context.Count.ToString("N0")</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>;
|
||||||
|
|
||||||
|
private RenderFragment<IReadOnlyList<HrKpiEmployeeRow>> EmployeesTable => items => @<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Mitarbeitende", "Employees")</MudText>
|
||||||
|
<MudTable Items="items.Take(250)" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Personalnr.", "Personnel no.")</MudTh>
|
||||||
|
<MudTh>@T("Name", "Name")</MudTh>
|
||||||
|
<MudTh>@T("Organisation", "Organisation")</MudTh>
|
||||||
|
<MudTh>@T("Kostenstelle", "Cost center")</MudTh>
|
||||||
|
<MudTh>FTE</MudTh>
|
||||||
|
<MudTh>@T("Alter", "Age")</MudTh>
|
||||||
|
<MudTh>@T("Dienstjahre", "Service years")</MudTh>
|
||||||
|
<MudTh>@T("Typ", "Type")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Personalnummer</MudTd>
|
||||||
|
<MudTd>@context.NameVoll</MudTd>
|
||||||
|
<MudTd>@context.Organisationseinheit</MudTd>
|
||||||
|
<MudTd>@context.KostenstelleText</MudTd>
|
||||||
|
<MudTd>@context.Fte.ToString("N2")</MudTd>
|
||||||
|
<MudTd>@context.AlterJahre</MudTd>
|
||||||
|
<MudTd>@context.Dienstjahre</MudTd>
|
||||||
|
<MudTd>@context.Mitarbeitertyp</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
<PagerContent>
|
||||||
|
<MudTablePager />
|
||||||
|
</PagerContent>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>;
|
||||||
|
|
||||||
|
private RenderFragment<IReadOnlyList<HrKpiFileStatus>> FileStatusTable => items => @<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Dateistatus", "File status")</MudText>
|
||||||
|
<MudTable Items="items" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Quelle", "Source")</MudTh>
|
||||||
|
<MudTh>@T("Status", "Status")</MudTh>
|
||||||
|
<MudTh>@T("Zeilen", "Rows")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>
|
||||||
|
<MudText Typo="Typo.body2">@context.Label</MudText>
|
||||||
|
<MudText Typo="Typo.caption">@context.Path</MudText>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="@(context.Exists ? Color.Success : Color.Error)" Variant="Variant.Outlined">
|
||||||
|
@(context.Message ?? "-")
|
||||||
|
</MudChip>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>@context.RowCount.ToString("N0")</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>;
|
||||||
|
|
||||||
|
private static IEnumerable<HrKpiGroupValue> BuildLeaverExclusionRows(IReadOnlyList<HrLeaverRow> items)
|
||||||
|
=> items
|
||||||
|
.GroupBy(x => x.FluktuationAusschlussgrund ?? "Relevant")
|
||||||
|
.Select(g => new HrKpiGroupValue { Label = g.Key, Count = g.Count(), Value = g.Count() })
|
||||||
|
.OrderByDescending(x => x.Count);
|
||||||
|
|
||||||
|
private RenderFragment<HrTurnoverVisuals> TurnoverGauge => visual => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Jahres-Fluktuation", "Annual turnover")</MudText>
|
||||||
|
<div class="hr-gauge" style="@($"--gauge-color:{visual.GaugeColor}; --gauge-deg:{visual.GaugeRotationDegrees.ToString("0", System.Globalization.CultureInfo.InvariantCulture)}deg")">
|
||||||
|
<div class="hr-gauge-track"></div>
|
||||||
|
<div class="hr-gauge-needle"></div>
|
||||||
|
<div class="hr-gauge-center">
|
||||||
|
<div class="hr-gauge-value">@visual.YearRateLabel</div>
|
||||||
|
<div class="hr-gauge-caption">0-20%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hr-gauge-scale">
|
||||||
|
<span>0%</span>
|
||||||
|
<span>8%</span>
|
||||||
|
<span>12%</span>
|
||||||
|
<span>20%+</span>
|
||||||
|
</div>
|
||||||
|
</MudPaper>;
|
||||||
|
|
||||||
|
private RenderFragment<IReadOnlyList<HrKpiGroupValue>> TurnoverFunnel => items => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Austritts-Funnel", "Leaver funnel")</MudText>
|
||||||
|
<div class="hr-funnel">
|
||||||
|
@foreach (var item in items)
|
||||||
|
{
|
||||||
|
<div class="hr-funnel-row">
|
||||||
|
<div class="hr-funnel-label">@item.Label</div>
|
||||||
|
<div class="hr-funnel-bar-wrap">
|
||||||
|
<div class="hr-funnel-bar" style="@($"width:{Math.Max(item.Percent, 3).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}%; background:{item.Color}")">
|
||||||
|
<span>@item.Count.ToString("N0")</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</MudPaper>;
|
||||||
|
|
||||||
|
private RenderFragment<IReadOnlyList<HrKpiGroupValue>> TurnoverDonut => items => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Ausschlussgruende", "Exclusion reasons")</MudText>
|
||||||
|
<div class="hr-donut-wrap">
|
||||||
|
<div class="hr-donut" style="@BuildDonutStyle(items)">
|
||||||
|
<div class="hr-donut-hole">@items.Sum(x => x.Count).ToString("N0")</div>
|
||||||
|
</div>
|
||||||
|
<div class="hr-donut-legend">
|
||||||
|
@foreach (var item in items.Take(7))
|
||||||
|
{
|
||||||
|
<div class="hr-legend-row">
|
||||||
|
<span class="hr-legend-dot" style="@($"background:{item.Color}")"></span>
|
||||||
|
<span>@item.Label</span>
|
||||||
|
<strong>@item.Count.ToString("N0")</strong>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MudPaper>;
|
||||||
|
|
||||||
|
private RenderFragment<IReadOnlyList<HrKpiGroupValue>> HorizontalBars => items => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Relevante Austritte nach Organisation", "Relevant leavers by organisation")</MudText>
|
||||||
|
<div class="hr-bars">
|
||||||
|
@foreach (var item in items)
|
||||||
|
{
|
||||||
|
<div class="hr-bar-row">
|
||||||
|
<div class="hr-bar-label">@item.Label</div>
|
||||||
|
<div class="hr-bar-track">
|
||||||
|
<div class="hr-bar-fill" style="@($"width:{Math.Max(item.Percent, 3).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}%; background:{item.Color}")"></div>
|
||||||
|
</div>
|
||||||
|
<div class="hr-bar-value">@item.Count.ToString("N0")</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</MudPaper>;
|
||||||
|
|
||||||
|
private RenderFragment<IReadOnlyList<HrKpiGroupValue>> MonthlyBars => items => @<MudPaper Class="pa-4 hr-viz-panel" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Relevante Austritte pro Monat", "Relevant leavers per month")</MudText>
|
||||||
|
<div class="hr-month-bars">
|
||||||
|
@foreach (var item in items)
|
||||||
|
{
|
||||||
|
<div class="hr-month">
|
||||||
|
<div class="hr-month-bar" style="@($"height:{Math.Max(item.Percent, item.Count > 0 ? 8 : 1).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}%; background:{item.Color}")"></div>
|
||||||
|
<div class="hr-month-value">@item.Count</div>
|
||||||
|
<div class="hr-month-label">@item.Label</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</MudPaper>;
|
||||||
|
|
||||||
|
private static string BuildDonutStyle(IReadOnlyList<HrKpiGroupValue> items)
|
||||||
|
{
|
||||||
|
var total = items.Sum(x => x.Count);
|
||||||
|
if (total <= 0)
|
||||||
|
return "background:#e0e0e0";
|
||||||
|
var current = 0m;
|
||||||
|
var segments = new List<string>();
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
var start = current;
|
||||||
|
current += item.Count / (decimal)total * 100m;
|
||||||
|
segments.Add($"{item.Color} {start.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}% {current.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}%");
|
||||||
|
}
|
||||||
|
return $"background:conic-gradient({string.Join(", ", segments)})";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hr-viz-panel {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-gauge {
|
||||||
|
--gauge-color: #2e7d32;
|
||||||
|
--gauge-deg: 0deg;
|
||||||
|
position: relative;
|
||||||
|
height: 170px;
|
||||||
|
display: grid;
|
||||||
|
place-items: end center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-gauge-track {
|
||||||
|
width: 260px;
|
||||||
|
height: 130px;
|
||||||
|
border-radius: 260px 260px 0 0;
|
||||||
|
background: conic-gradient(from 270deg at 50% 100%, #2e7d32 0deg 72deg, #f9a825 72deg 108deg, #c62828 108deg 180deg, transparent 180deg 360deg);
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-gauge-track::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 34px;
|
||||||
|
right: 34px;
|
||||||
|
bottom: 0;
|
||||||
|
height: 96px;
|
||||||
|
border-radius: 192px 192px 0 0;
|
||||||
|
background: var(--mud-palette-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-gauge-needle {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 4px;
|
||||||
|
width: 4px;
|
||||||
|
height: 112px;
|
||||||
|
background: #263238;
|
||||||
|
transform-origin: bottom center;
|
||||||
|
transform: rotate(calc(var(--gauge-deg) - 90deg));
|
||||||
|
border-radius: 4px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-gauge-center {
|
||||||
|
z-index: 3;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-gauge-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gauge-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-gauge-caption,
|
||||||
|
.hr-gauge-scale {
|
||||||
|
color: var(--mud-palette-text-secondary);
|
||||||
|
font-size: .8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-gauge-scale {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
max-width: 280px;
|
||||||
|
margin: 4px auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-funnel-row,
|
||||||
|
.hr-bar-row,
|
||||||
|
.hr-legend-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(110px, 1fr) 2fr auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin: 9px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-funnel-bar-wrap,
|
||||||
|
.hr-bar-track {
|
||||||
|
background: rgba(0,0,0,.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
height: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-funnel-bar,
|
||||||
|
.hr-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-right: 8px;
|
||||||
|
min-width: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-donut-wrap {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 150px 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-donut {
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-donut::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--mud-palette-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-donut-hole {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
z-index: 2;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-legend-row {
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
margin: 6px 0;
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-legend-dot {
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-bars {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-bar-row {
|
||||||
|
grid-template-columns: minmax(130px, 1.2fr) 2fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-month-bars {
|
||||||
|
height: 240px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(12, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-month {
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 1fr auto auto;
|
||||||
|
align-items: end;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--mud-palette-text-secondary);
|
||||||
|
font-size: .8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-month-bar {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2px;
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-month-value {
|
||||||
|
color: var(--mud-palette-text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@@media (max-width: 700px) {
|
||||||
|
.hr-donut-wrap {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-funnel-row,
|
||||||
|
.hr-bar-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
namespace TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
public sealed class HrKpiOptions
|
||||||
|
{
|
||||||
|
public string DataFolder { get; set; } = @"C:\temp";
|
||||||
|
public int Year { get; set; } = DateTime.Today.Year;
|
||||||
|
public DateTime? FromDate { get; set; }
|
||||||
|
public DateTime? ToDate { get; set; }
|
||||||
|
public int? EntryYear { get; set; }
|
||||||
|
public string? Organisationseinheit { get; set; }
|
||||||
|
public string? KostenstelleText { 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 sealed class HrKpiResult
|
||||||
|
{
|
||||||
|
public HrKpiOptions Options { get; set; } = new();
|
||||||
|
public List<HrKpiFileStatus> FileStatuses { get; set; } = [];
|
||||||
|
public List<string> Notices { get; set; } = [];
|
||||||
|
public List<string> OrganisationOptions { get; set; } = [];
|
||||||
|
public List<string> KostenstelleOptions { get; set; } = [];
|
||||||
|
public List<int> EntryYearOptions { get; set; } = [];
|
||||||
|
public List<string> MitarbeitertypOptions { get; set; } = [];
|
||||||
|
public List<HrKpiMetric> Metrics { get; set; } = [];
|
||||||
|
public List<HrKpiMetric> TurnoverMetrics { get; set; } = [];
|
||||||
|
public List<HrKpiMetric> AbsenceMetrics { get; set; } = [];
|
||||||
|
public List<HrKpiMetric> TimeVacationMetrics { get; set; } = [];
|
||||||
|
public List<HrKpiEmployeeRow> Employees { get; set; } = [];
|
||||||
|
public List<HrAbsenceRow> Absences { get; set; } = [];
|
||||||
|
public List<HrLeaverRow> Leavers { get; set; } = [];
|
||||||
|
public List<HrKpiGroupValue> HeadcountByOrganisation { get; set; } = [];
|
||||||
|
public List<HrKpiEmployeeRow> CriticalTimeBalances { get; set; } = [];
|
||||||
|
public List<HrLeaverRow> FluctuationRelevantLeavers { get; set; } = [];
|
||||||
|
public HrTurnoverVisuals TurnoverVisuals { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class HrKpiFileStatus
|
||||||
|
{
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
public string Path { get; set; } = string.Empty;
|
||||||
|
public bool Exists { get; set; }
|
||||||
|
public int RowCount { get; set; }
|
||||||
|
public string? Message { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class HrKpiMetric
|
||||||
|
{
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
public string Value { get; set; } = string.Empty;
|
||||||
|
public string Detail { get; set; } = string.Empty;
|
||||||
|
public string Severity { get; set; } = "Normal";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class HrKpiGroupValue
|
||||||
|
{
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
public decimal Value { get; set; }
|
||||||
|
public int Count { get; set; }
|
||||||
|
public string Color { get; set; } = "#607d8b";
|
||||||
|
public decimal Percent { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class HrTurnoverVisuals
|
||||||
|
{
|
||||||
|
public decimal YearRatePercent { get; set; }
|
||||||
|
public string YearRateLabel { get; set; } = "0.0%";
|
||||||
|
public string GaugeColor { get; set; } = "#2e7d32";
|
||||||
|
public decimal GaugeRotationDegrees { get; set; }
|
||||||
|
public List<HrKpiGroupValue> FunnelSteps { get; set; } = [];
|
||||||
|
public List<HrKpiGroupValue> ExclusionReasons { get; set; } = [];
|
||||||
|
public List<HrKpiGroupValue> RelevantByOrganisation { get; set; } = [];
|
||||||
|
public List<HrKpiGroupValue> MonthlyRelevantLeavers { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class HrKpiEmployeeRow
|
||||||
|
{
|
||||||
|
public int? Personalnummer { get; set; }
|
||||||
|
public string NameVoll { get; set; } = string.Empty;
|
||||||
|
public string Vorname { get; set; } = string.Empty;
|
||||||
|
public string Nachname { get; set; } = string.Empty;
|
||||||
|
public string Organisationseinheit { get; set; } = string.Empty;
|
||||||
|
public string KostenstelleText { get; set; } = string.Empty;
|
||||||
|
public int? Kostenstelle { get; set; }
|
||||||
|
public string Stelle { get; set; } = string.Empty;
|
||||||
|
public string Leitung { get; set; } = string.Empty;
|
||||||
|
public DateTime? Eintrittsdatum { get; set; }
|
||||||
|
public DateTime? Geburtsdatum { get; set; }
|
||||||
|
public int? AlterJahre { get; set; }
|
||||||
|
public string Altersgruppe { get; set; } = "Unbekannt";
|
||||||
|
public string GeschlechtText { get; set; } = "Unbekannt";
|
||||||
|
public decimal? BeschaeftigungsgradProzent { get; set; }
|
||||||
|
public decimal Fte { get; set; }
|
||||||
|
public bool IstTeilzeit { get; set; }
|
||||||
|
public int? Dienstjahre { get; set; }
|
||||||
|
public bool IstAktiv { get; set; }
|
||||||
|
public string Mitarbeitertyp { get; set; } = "Festangestellt";
|
||||||
|
public decimal StundenSaldo { get; set; }
|
||||||
|
public string GlzAmpel { get; set; } = "Gruen";
|
||||||
|
public decimal UrlaubRest { get; set; }
|
||||||
|
public decimal Urlaubsanspruch { get; set; }
|
||||||
|
public decimal FerienAusstehend { get; set; }
|
||||||
|
public decimal Ferientage { get; set; }
|
||||||
|
public string RestferienAmpel { get; set; } = "Gruen";
|
||||||
|
public decimal Bruttolohn { get; set; }
|
||||||
|
public string LohnWaehrung { get; set; } = string.Empty;
|
||||||
|
public decimal BuTage { get; set; }
|
||||||
|
public decimal NbuTage { get; set; }
|
||||||
|
public string Buchungskreis { get; set; } = string.Empty;
|
||||||
|
public string Personalbereich { get; set; } = string.Empty;
|
||||||
|
public string Personalteilbereich { get; set; } = string.Empty;
|
||||||
|
public string Mitarbeitergruppe { get; set; } = string.Empty;
|
||||||
|
public string Mitarbeiterkreis { get; set; } = string.Empty;
|
||||||
|
public string Planstelle { get; set; } = string.Empty;
|
||||||
|
public string SollStelle { get; set; } = string.Empty;
|
||||||
|
public DateTime Periode { get; set; } = new(DateTime.Today.Year, DateTime.Today.Month, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class HrAbsenceRow
|
||||||
|
{
|
||||||
|
public int? Personalnummer { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Organisationseinheit { get; set; } = string.Empty;
|
||||||
|
public string Stelle { get; set; } = string.Empty;
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
public decimal KrankheitKurzStd { get; set; }
|
||||||
|
public decimal KrankheitLangStd { get; set; }
|
||||||
|
public decimal KrankheitGesamtStd { get; set; }
|
||||||
|
public decimal KrankheitstageGesamt { get; set; }
|
||||||
|
public decimal KrankheitstageKurz { get; set; }
|
||||||
|
public decimal KrankheitstageLang { get; set; }
|
||||||
|
public decimal KrankenquoteMa { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class HrLeaverRow
|
||||||
|
{
|
||||||
|
public int? Personalnummer { get; set; }
|
||||||
|
public string NameVoll { get; set; } = string.Empty;
|
||||||
|
public string Vorname { get; set; } = string.Empty;
|
||||||
|
public string Nachname { get; set; } = string.Empty;
|
||||||
|
public string Organisationseinheit { get; set; } = string.Empty;
|
||||||
|
public string Stelle { get; set; } = string.Empty;
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
public DateTime? Austrittsdatum { get; set; }
|
||||||
|
public DateTime? Eintrittsdatum { get; set; }
|
||||||
|
public decimal? VerweildauerMonate { get; set; }
|
||||||
|
public string Austrittsart { get; set; } = string.Empty;
|
||||||
|
public string AustrittsartNormalisiert { get; set; } = string.Empty;
|
||||||
|
public string Mitarbeitertyp { get; set; } = "Festangestellt";
|
||||||
|
public bool IstArbeitnehmerkuendigung { get; set; }
|
||||||
|
public bool IstFluktuationAusgeschlossen { get; set; }
|
||||||
|
public bool IstFluktuationsrelevant { get; set; }
|
||||||
|
public string? FluktuationAusschlussgrund { get; set; }
|
||||||
|
public DateTime? Austrittsmonat { get; set; }
|
||||||
|
public int? Austrittsjahr { get; set; }
|
||||||
|
}
|
||||||
@@ -70,6 +70,7 @@ builder.Services.AddSingleton<ITransformationCatalog, TransformationCatalog>();
|
|||||||
builder.Services.AddSingleton<IRecordTransformationService, RecordTransformationService>();
|
builder.Services.AddSingleton<IRecordTransformationService, RecordTransformationService>();
|
||||||
builder.Services.AddSingleton<IAppEventLogService, AppEventLogService>();
|
builder.Services.AddSingleton<IAppEventLogService, AppEventLogService>();
|
||||||
builder.Services.AddSingleton<IManagementCockpitService, ManagementCockpitService>();
|
builder.Services.AddSingleton<IManagementCockpitService, ManagementCockpitService>();
|
||||||
|
builder.Services.AddSingleton<IHrKpiService, HrKpiService>();
|
||||||
builder.Services.AddSingleton<IManualExcelImportService, ManualExcelImportService>();
|
builder.Services.AddSingleton<IManualExcelImportService, ManualExcelImportService>();
|
||||||
builder.Services.AddSingleton<IConsolidatedExportService, ConsolidatedExportService>();
|
builder.Services.AddSingleton<IConsolidatedExportService, ConsolidatedExportService>();
|
||||||
builder.Services.AddSingleton<IExportLogService, ExportLogService>();
|
builder.Services.AddSingleton<IExportLogService, ExportLogService>();
|
||||||
|
|||||||
@@ -0,0 +1,992 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using ClosedXML.Excel;
|
||||||
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
public interface IHrKpiService
|
||||||
|
{
|
||||||
|
Task<HrKpiResult> BuildAsync(HrKpiOptions options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class HrKpiService : IHrKpiService
|
||||||
|
{
|
||||||
|
private const string MainFile = "Saldiperstichdatum.xlsx";
|
||||||
|
private const string TimeFile = "Exportkommengehen.xlsx";
|
||||||
|
private const string SapFile = "HR_KPI_Export.xlsx";
|
||||||
|
private const string AbsenceFile = "Abwesenheitinstunden.xlsx";
|
||||||
|
private const string LeaverFile = "Personalausgeschieden.xlsx";
|
||||||
|
|
||||||
|
public Task<HrKpiResult> BuildAsync(HrKpiOptions options)
|
||||||
|
{
|
||||||
|
var normalizedOptions = new HrKpiOptions
|
||||||
|
{
|
||||||
|
DataFolder = string.IsNullOrWhiteSpace(options.DataFolder) ? @"C:\temp" : options.DataFolder.Trim(),
|
||||||
|
Year = options.Year <= 0 ? DateTime.Today.Year : options.Year,
|
||||||
|
FromDate = options.FromDate?.Date,
|
||||||
|
ToDate = options.ToDate?.Date,
|
||||||
|
EntryYear = options.EntryYear,
|
||||||
|
Organisationseinheit = NormalizeFilter(options.Organisationseinheit),
|
||||||
|
KostenstelleText = NormalizeFilter(options.KostenstelleText),
|
||||||
|
Mitarbeitertyp = NormalizeFilter(options.Mitarbeitertyp),
|
||||||
|
FluktuationFilter = NormalizeFilter(options.FluktuationFilter),
|
||||||
|
GlzAmpel = NormalizeFilter(options.GlzAmpel),
|
||||||
|
RestferienAmpel = NormalizeFilter(options.RestferienAmpel),
|
||||||
|
SearchText = NormalizeFilter(options.SearchText)
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = new HrKpiResult { Options = normalizedOptions };
|
||||||
|
var context = new ImportContext(result, normalizedOptions.DataFolder);
|
||||||
|
|
||||||
|
var timeRows = LoadTimeRows(context);
|
||||||
|
var sapRows = LoadSapRows(context);
|
||||||
|
var employees = LoadEmployees(context, timeRows, sapRows);
|
||||||
|
var absences = LoadAbsences(context);
|
||||||
|
var leavers = LoadLeavers(context);
|
||||||
|
|
||||||
|
result.OrganisationOptions = employees
|
||||||
|
.Select(x => x.Organisationseinheit)
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
result.KostenstelleOptions = employees
|
||||||
|
.Select(x => x.KostenstelleText)
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
result.EntryYearOptions = employees
|
||||||
|
.Where(x => x.Eintrittsdatum.HasValue)
|
||||||
|
.Select(x => x.Eintrittsdatum!.Value.Year)
|
||||||
|
.Distinct()
|
||||||
|
.OrderByDescending(x => x)
|
||||||
|
.ToList();
|
||||||
|
result.MitarbeitertypOptions = employees
|
||||||
|
.Select(x => x.Mitarbeitertyp)
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var analysisYear = ResolveAnalysisYear(normalizedOptions);
|
||||||
|
var filteredEmployees = ApplyEmployeeFilters(employees, normalizedOptions).ToList();
|
||||||
|
var filteredEmployeeNumbers = filteredEmployees
|
||||||
|
.Where(x => x.Personalnummer.HasValue)
|
||||||
|
.Select(x => x.Personalnummer!.Value)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
employees = filteredEmployees;
|
||||||
|
absences = ApplyAbsenceFilters(absences, normalizedOptions, filteredEmployeeNumbers).ToList();
|
||||||
|
leavers = ApplyLeaverFilters(leavers, normalizedOptions).ToList();
|
||||||
|
|
||||||
|
result.Employees = employees;
|
||||||
|
result.Absences = absences;
|
||||||
|
result.Leavers = leavers;
|
||||||
|
result.Metrics = BuildOverviewMetrics(employees, absences, leavers, analysisYear);
|
||||||
|
result.TurnoverMetrics = BuildTurnoverMetrics(employees, leavers, analysisYear, ResolveTurnoverAnchorDate(normalizedOptions, analysisYear));
|
||||||
|
result.AbsenceMetrics = BuildAbsenceMetrics(employees, absences);
|
||||||
|
result.TimeVacationMetrics = BuildTimeVacationMetrics(employees);
|
||||||
|
result.TurnoverVisuals = BuildTurnoverVisuals(employees, leavers, analysisYear);
|
||||||
|
result.HeadcountByOrganisation = employees
|
||||||
|
.GroupBy(x => BlankAsUnknown(x.Organisationseinheit), StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(g => new HrKpiGroupValue
|
||||||
|
{
|
||||||
|
Label = g.Key,
|
||||||
|
Count = g.Select(x => x.Personalnummer).Distinct().Count(),
|
||||||
|
Value = g.Sum(x => x.Fte)
|
||||||
|
})
|
||||||
|
.OrderByDescending(x => x.Count)
|
||||||
|
.ThenBy(x => x.Label, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Take(12)
|
||||||
|
.ToList();
|
||||||
|
result.CriticalTimeBalances = employees
|
||||||
|
.OrderByDescending(x => Math.Abs(x.StundenSaldo))
|
||||||
|
.Take(25)
|
||||||
|
.ToList();
|
||||||
|
result.FluctuationRelevantLeavers = leavers
|
||||||
|
.Where(x => x.IstFluktuationsrelevant)
|
||||||
|
.OrderByDescending(x => x.Austrittsdatum)
|
||||||
|
.Take(25)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (employees.Count == 0)
|
||||||
|
result.Notices.Add("Keine aktiven Mitarbeitenden geladen. Pruefe Saldiperstichdatum.xlsx und die Filter.");
|
||||||
|
var missingEmployeeNumberCount = employees.Count(x => !x.Personalnummer.HasValue);
|
||||||
|
if (missingEmployeeNumberCount > 0)
|
||||||
|
result.Notices.Add($"{missingEmployeeNumberCount:N0} aktive Mitarbeitendenzeilen ohne Personalnummer werden in Headcount-Distinct-Kennzahlen nicht mitgezaehlt.");
|
||||||
|
var missingFteCount = employees.Count(x => !x.BeschaeftigungsgradProzent.HasValue);
|
||||||
|
if (missingFteCount > 0)
|
||||||
|
result.Notices.Add($"{missingFteCount:N0} aktive Mitarbeitendenzeilen ohne SAP-Beschaeftigungsgrad verwenden einen FTE-Fallback aus Rexx-Arbeitszeitmodell/Sollzeit.");
|
||||||
|
if (!context.HasFile(MainFile))
|
||||||
|
result.Notices.Add("Hauptdatei fehlt: Saldiperstichdatum.xlsx. Ohne diese Datei sind keine HR-KPIs moeglich.");
|
||||||
|
if (!context.HasFile(SapFile))
|
||||||
|
result.Notices.Add("SAP-Datei HR_KPI_Export.xlsx fehlt. SAP-only Felder wie Geschlecht, Beschaeftigungsgrad, BU/NBU und Planstelle bleiben leer.");
|
||||||
|
if (!context.HasFile(AbsenceFile))
|
||||||
|
result.Notices.Add("Rexx-Absenzen fehlen. Absenzquote und Krankheitstage bleiben 0.");
|
||||||
|
if (!context.HasFile(LeaverFile))
|
||||||
|
result.Notices.Add("Rexx-Austritte fehlen. Fluktuationskennzahlen bleiben 0.");
|
||||||
|
|
||||||
|
return Task.FromResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<HrKpiEmployeeRow> LoadEmployees(
|
||||||
|
ImportContext context,
|
||||||
|
IReadOnlyDictionary<string, TimeRow> timeRows,
|
||||||
|
IReadOnlyDictionary<string, SapRow> sapRows)
|
||||||
|
{
|
||||||
|
return context.ReadRows(MainFile, "Rexx #757 Saldi", (row, headers) =>
|
||||||
|
{
|
||||||
|
var personalnummer = ReadInt(row, headers, "Personalnummer");
|
||||||
|
var name = ReadString(row, headers, "Nachname, Vorname (Link Personal)", "Name_Rexx");
|
||||||
|
var key = BuildPersonalKey(personalnummer);
|
||||||
|
timeRows.TryGetValue(NormalizeKey(name), out var time);
|
||||||
|
if (!sapRows.TryGetValue(key, out var sap) && personalnummer.HasValue)
|
||||||
|
sapRows.TryGetValue(personalnummer.Value.ToString(CultureInfo.InvariantCulture), out sap);
|
||||||
|
|
||||||
|
var entryDate = ReadDate(row, headers, "Eintrittsdatum");
|
||||||
|
var birthDate = time?.Geburtsdatum;
|
||||||
|
var status = ReadString(row, headers, "Personal Status", "Personal_Status");
|
||||||
|
var rawBalance = ReadString(row, headers, "Stunden Saldo", "Stunden_Saldo_Raw");
|
||||||
|
var balance = ParseTimeBalance(rawBalance);
|
||||||
|
var percent = sap?.BeschaeftigungsgradProzent;
|
||||||
|
var arbeitzeitmodell = time?.Arbeitszeitmodell ?? string.Empty;
|
||||||
|
var fte = ResolveFte(percent, arbeitzeitmodell, time?.AvgSollzeitTag);
|
||||||
|
|
||||||
|
var nameParts = SplitName(name);
|
||||||
|
var urlaubsanspruch = ReadDecimal(row, headers, "Urlaubsanspruch", "Urlaubsanspruch_Raw");
|
||||||
|
var urlaubRest = ReadDecimal(row, headers, "Urlaub Rest", "Urlaub_Rest_Raw");
|
||||||
|
var ferienAusstehend = ReadDecimal(row, headers, "Ferien ausstehend (Tage)", "Ferien_Ausstehend_Raw");
|
||||||
|
var ferienBezogen = urlaubsanspruch - urlaubRest - ferienAusstehend;
|
||||||
|
|
||||||
|
return new HrKpiEmployeeRow
|
||||||
|
{
|
||||||
|
Personalnummer = personalnummer,
|
||||||
|
NameVoll = name,
|
||||||
|
Vorname = nameParts.Vorname,
|
||||||
|
Nachname = nameParts.Nachname,
|
||||||
|
Organisationseinheit = ReadString(row, headers, "Organisation", "Organisation_Text"),
|
||||||
|
KostenstelleText = ReadString(row, headers, "Kostenstelle", "Kostenstelle_Rexx"),
|
||||||
|
Kostenstelle = ParseCostCenter(ReadString(row, headers, "Kostenstelle", "Kostenstelle_Rexx")),
|
||||||
|
Stelle = ReadString(row, headers, "Stelle", "Stelle_Rexx"),
|
||||||
|
Leitung = ReadString(row, headers, "Leitung j/n", "Leitung"),
|
||||||
|
Eintrittsdatum = entryDate,
|
||||||
|
Geburtsdatum = birthDate,
|
||||||
|
AlterJahre = YearsSince(birthDate),
|
||||||
|
Altersgruppe = BuildAgeGroup(YearsSince(birthDate)),
|
||||||
|
GeschlechtText = MapGender(sap?.Geschlecht),
|
||||||
|
BeschaeftigungsgradProzent = percent,
|
||||||
|
Fte = fte,
|
||||||
|
IstTeilzeit = percent.HasValue && percent.Value > 0
|
||||||
|
? percent.Value < 100
|
||||||
|
: string.Equals(arbeitzeitmodell, "Teilzeit", StringComparison.OrdinalIgnoreCase),
|
||||||
|
Dienstjahre = YearsSince(entryDate),
|
||||||
|
IstAktiv = string.Equals(status, "Aktiv", StringComparison.OrdinalIgnoreCase),
|
||||||
|
Mitarbeitertyp = BuildEmployeeType(ReadString(row, headers, "Stelle", "Stelle_Rexx")),
|
||||||
|
StundenSaldo = balance,
|
||||||
|
GlzAmpel = BuildTrafficLight(balance),
|
||||||
|
UrlaubRest = urlaubRest,
|
||||||
|
Urlaubsanspruch = urlaubsanspruch,
|
||||||
|
FerienAusstehend = ferienAusstehend,
|
||||||
|
Ferientage = ferienBezogen < 0 ? 0 : ferienBezogen,
|
||||||
|
RestferienAmpel = urlaubRest <= 5 ? "Gruen" : "Rot",
|
||||||
|
Bruttolohn = ReadDecimal(row, headers, "Lohn", "Lohn_Raw"),
|
||||||
|
LohnWaehrung = ReadString(row, headers, "Lohn Waehrung", "Lohn Währung"),
|
||||||
|
BuTage = sap?.BuTage ?? 0,
|
||||||
|
NbuTage = sap?.NbuTage ?? 0,
|
||||||
|
Buchungskreis = sap?.Buchungskreis ?? string.Empty,
|
||||||
|
Personalbereich = sap?.Personalbereich ?? string.Empty,
|
||||||
|
Personalteilbereich = sap?.Personalteilbereich ?? string.Empty,
|
||||||
|
Mitarbeitergruppe = sap?.Mitarbeitergruppe ?? string.Empty,
|
||||||
|
Mitarbeiterkreis = sap?.Mitarbeiterkreis ?? string.Empty,
|
||||||
|
Planstelle = sap?.Planstelle ?? string.Empty,
|
||||||
|
SollStelle = sap?.SollStelle ?? string.Empty,
|
||||||
|
Periode = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.Where(x => x.IstAktiv)
|
||||||
|
.OrderBy(x => x.Personalnummer ?? int.MaxValue)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, TimeRow> LoadTimeRows(ImportContext context)
|
||||||
|
{
|
||||||
|
var rows = context.ReadRows(TimeFile, "Rexx #732 Kommen/Gehen", (row, headers) =>
|
||||||
|
{
|
||||||
|
var name = ReadString(row, headers, "Nachname, Vorname (Link Personal)");
|
||||||
|
return new TimeRow(
|
||||||
|
NormalizeKey(name),
|
||||||
|
ReadDate(row, headers, "Geburtsdatum"),
|
||||||
|
ReadString(row, headers, "Arbeitszeitmodell"),
|
||||||
|
ReadDecimal(row, headers, "O taegliche Sollarbeitszeit (Woche)", "Ø tägliche Sollarbeitszeit (Woche)", "Ø tägliche Sollarbeitszeit (Woche)"));
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x.NameKey))
|
||||||
|
.GroupBy(x => x.NameKey, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, SapRow> LoadSapRows(ImportContext context)
|
||||||
|
{
|
||||||
|
var rows = context.ReadRows(SapFile, "SAP HR KPI", (row, headers) =>
|
||||||
|
{
|
||||||
|
var pernr = ReadInt(row, headers, "Personalnummer");
|
||||||
|
return new SapRow(
|
||||||
|
BuildPersonalKey(pernr),
|
||||||
|
ReadString(row, headers, "Buchungskreis"),
|
||||||
|
ReadString(row, headers, "Personalbereich"),
|
||||||
|
ReadString(row, headers, "Personalteilbereich"),
|
||||||
|
ReadString(row, headers, "Mitarbeitergruppe"),
|
||||||
|
ReadString(row, headers, "Mitarbeiterkreis"),
|
||||||
|
ReadString(row, headers, "Teilzeitkraft", "Teilzeitkennzeichen"),
|
||||||
|
ReadDecimalNullable(row, headers, "Beschaeftigungsgrad %", "Beschäftigungsgrad %", "Beschäftigungsgrad %", "Beschaeftigungsgrad_Prozent"),
|
||||||
|
ReadInt(row, headers, "Geschlecht"),
|
||||||
|
ReadString(row, headers, "Planstelle"),
|
||||||
|
ReadString(row, headers, "Stellenschluessel", "Stellenschlüssel", "Stellenschlüssel", "Soll_Stelle"),
|
||||||
|
ReadDecimal(row, headers, "Nichtberufsunfall Tage", "NBU_Tage"),
|
||||||
|
ReadDecimal(row, headers, "Berufsunfall Tage", "BU_Tage"),
|
||||||
|
ReadString(row, headers, "Abrechnungskreis"));
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x.PersonalKey))
|
||||||
|
.GroupBy(x => x.PersonalKey, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<HrAbsenceRow> LoadAbsences(ImportContext context)
|
||||||
|
{
|
||||||
|
return context.ReadRows(AbsenceFile, "Rexx #744 Absenzen", (row, headers) =>
|
||||||
|
{
|
||||||
|
var kurz = ReadDecimal(row, headers, "Krankheit angetreten (Stunden Ind.)", "Krankheit_Kurz_Std");
|
||||||
|
var lang = ReadDecimal(row, headers, "Krank nicht buchbar angetreten (Stunden Ind.)", "Krankheit_Lang_Std");
|
||||||
|
var gesamt = kurz + lang;
|
||||||
|
var tage = Math.Round(gesamt / 8.4m, 1);
|
||||||
|
return new HrAbsenceRow
|
||||||
|
{
|
||||||
|
Personalnummer = ReadInt(row, headers, "Personalnummer"),
|
||||||
|
Name = ReadString(row, headers, "Nachname, Vorname (Link Personal)", "Name"),
|
||||||
|
Organisationseinheit = ReadString(row, headers, "Organisation"),
|
||||||
|
Stelle = ReadString(row, headers, "Stelle"),
|
||||||
|
Status = ReadString(row, headers, "Personal Status", "Status"),
|
||||||
|
KrankheitKurzStd = kurz,
|
||||||
|
KrankheitLangStd = lang,
|
||||||
|
KrankheitGesamtStd = gesamt,
|
||||||
|
KrankheitstageGesamt = tage,
|
||||||
|
KrankheitstageKurz = Math.Round(kurz / 8.4m, 1),
|
||||||
|
KrankheitstageLang = Math.Round(lang / 8.4m, 1),
|
||||||
|
KrankenquoteMa = tage == 0 ? 0 : tage / 21m
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.Where(x => string.Equals(x.Status, "Aktiv", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<HrLeaverRow> LoadLeavers(ImportContext context)
|
||||||
|
{
|
||||||
|
return context.ReadRows(LeaverFile, "Rexx #381 Ausgeschieden", (row, headers) =>
|
||||||
|
{
|
||||||
|
var name = ReadString(row, headers, "Nachname, Vorname (Link Personal)", "Name_Voll");
|
||||||
|
var nameParts = SplitName(name);
|
||||||
|
var austritt = ReadDate(row, headers, "Austrittsdatum");
|
||||||
|
var eintritt = ReadDate(row, headers, "Eintrittsdatum");
|
||||||
|
var stelle = ReadString(row, headers, "Stelle-1", "Stelle");
|
||||||
|
var type = BuildEmployeeType(stelle);
|
||||||
|
var reason = ReadString(row, headers, "Austrittsart");
|
||||||
|
var normalizedReason = NormalizeReason(reason);
|
||||||
|
var isEmployeeResignation =
|
||||||
|
normalizedReason.Contains("arbeitnehmer", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalizedReason.Contains("mitarbeiter", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalizedReason.Contains("kuendigung an", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalizedReason.Contains("an kuendigung", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalizedReason.Contains("eigenkuendigung", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalizedReason.Contains("kuendigung ma", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalizedReason.Contains("kuendigung durch ma", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var isExcluded =
|
||||||
|
!string.Equals(type, "Festangestellt", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalizedReason.Contains("befrist", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalizedReason.Contains("pension", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalizedReason.Contains("rente", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalizedReason.Contains("trafag", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalizedReason.Contains("arbeitgeber", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalizedReason.Contains("ag-kuendigung", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalizedReason.Contains("ag kuendigung", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalizedReason.Contains("kuendigung ag", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var isRelevant = isEmployeeResignation && !isExcluded;
|
||||||
|
|
||||||
|
return new HrLeaverRow
|
||||||
|
{
|
||||||
|
Personalnummer = ReadInt(row, headers, "Personalnummer"),
|
||||||
|
NameVoll = name,
|
||||||
|
Vorname = nameParts.Vorname,
|
||||||
|
Nachname = nameParts.Nachname,
|
||||||
|
Organisationseinheit = ReadString(row, headers, "Organisation-1", "Organisationseinheit"),
|
||||||
|
Stelle = stelle,
|
||||||
|
Status = ReadString(row, headers, "Personal Status", "Status"),
|
||||||
|
Austrittsdatum = austritt,
|
||||||
|
Eintrittsdatum = eintritt,
|
||||||
|
VerweildauerMonate = austritt.HasValue && eintritt.HasValue
|
||||||
|
? Math.Round((decimal)(austritt.Value - eintritt.Value).TotalDays / 30.44m, 1)
|
||||||
|
: null,
|
||||||
|
Austrittsart = reason,
|
||||||
|
AustrittsartNormalisiert = normalizedReason,
|
||||||
|
Mitarbeitertyp = type,
|
||||||
|
IstArbeitnehmerkuendigung = isEmployeeResignation,
|
||||||
|
IstFluktuationAusgeschlossen = isExcluded,
|
||||||
|
IstFluktuationsrelevant = isRelevant,
|
||||||
|
FluktuationAusschlussgrund = isRelevant ? null : BuildExclusionReason(type, normalizedReason, isEmployeeResignation),
|
||||||
|
Austrittsmonat = austritt.HasValue ? new DateTime(austritt.Value.Year, austritt.Value.Month, 1) : null,
|
||||||
|
Austrittsjahr = austritt?.Year
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<HrKpiEmployeeRow> ApplyEmployeeFilters(IEnumerable<HrKpiEmployeeRow> rows, HrKpiOptions options)
|
||||||
|
=> rows.Where(x => MatchesFilter(x.Organisationseinheit, options.Organisationseinheit) &&
|
||||||
|
MatchesFilter(x.KostenstelleText, options.KostenstelleText) &&
|
||||||
|
MatchesFilter(x.Mitarbeitertyp, options.Mitarbeitertyp) &&
|
||||||
|
MatchesFilter(x.GlzAmpel, options.GlzAmpel) &&
|
||||||
|
MatchesFilter(x.RestferienAmpel, options.RestferienAmpel) &&
|
||||||
|
(!options.EntryYear.HasValue || x.Eintrittsdatum?.Year == options.EntryYear.Value) &&
|
||||||
|
MatchesEmployeeSearch(x, options.SearchText));
|
||||||
|
|
||||||
|
private static IEnumerable<HrAbsenceRow> ApplyAbsenceFilters(
|
||||||
|
IEnumerable<HrAbsenceRow> rows,
|
||||||
|
HrKpiOptions options,
|
||||||
|
IReadOnlySet<int> filteredEmployeeNumbers)
|
||||||
|
=> rows.Where(x => MatchesFilter(x.Organisationseinheit, options.Organisationseinheit) &&
|
||||||
|
x.Personalnummer.HasValue &&
|
||||||
|
filteredEmployeeNumbers.Contains(x.Personalnummer.Value) &&
|
||||||
|
MatchesTextSearch(options.SearchText, x.Name, x.Personalnummer?.ToString(CultureInfo.InvariantCulture) ?? string.Empty));
|
||||||
|
|
||||||
|
private static IEnumerable<HrLeaverRow> ApplyLeaverFilters(IEnumerable<HrLeaverRow> rows, HrKpiOptions options)
|
||||||
|
=> rows.Where(x => MatchesLeaverDateFilter(x, options) &&
|
||||||
|
MatchesFilter(x.Organisationseinheit, options.Organisationseinheit) &&
|
||||||
|
MatchesFilter(x.Mitarbeitertyp, options.Mitarbeitertyp) &&
|
||||||
|
MatchesFluctuationFilter(x, options.FluktuationFilter) &&
|
||||||
|
MatchesTextSearch(options.SearchText, x.NameVoll, x.Personalnummer?.ToString(CultureInfo.InvariantCulture) ?? string.Empty));
|
||||||
|
|
||||||
|
private static List<HrKpiMetric> BuildOverviewMetrics(
|
||||||
|
IReadOnlyCollection<HrKpiEmployeeRow> employees,
|
||||||
|
IReadOnlyCollection<HrAbsenceRow> absences,
|
||||||
|
IReadOnlyCollection<HrLeaverRow> leavers,
|
||||||
|
int year)
|
||||||
|
{
|
||||||
|
var activeCount = CountDistinctPersons(employees.Select(x => x.Personalnummer));
|
||||||
|
var fixedCount = CountDistinctPersons(employees
|
||||||
|
.Where(x => string.Equals(x.Mitarbeitertyp, "Festangestellt", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(x => x.Personalnummer));
|
||||||
|
var fte = employees.Sum(x => x.Fte);
|
||||||
|
var sickDays = absences.Sum(x => x.KrankheitstageGesamt);
|
||||||
|
var absenceRate = activeCount == 0 ? 0 : sickDays / (activeCount * 21m);
|
||||||
|
var relevantLeavers = CountDistinctPersons(leavers.Where(x => x.IstFluktuationsrelevant).Select(x => x.Personalnummer));
|
||||||
|
var employeeLeavers = CountDistinctPersons(leavers.Where(x => x.IstArbeitnehmerkuendigung).Select(x => x.Personalnummer));
|
||||||
|
var turnover = fixedCount == 0 ? 0 : relevantLeavers / (decimal)fixedCount;
|
||||||
|
var avgBalance = activeCount == 0 ? 0 : employees.Average(x => x.StundenSaldo);
|
||||||
|
var redBalance = employees.Count(x => x.GlzAmpel == "Rot");
|
||||||
|
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new() { Label = "Headcount aktiv", Value = activeCount.ToString("N0"), Detail = $"{fixedCount:N0} festangestellt", Severity = "Normal" },
|
||||||
|
new() { Label = "FTE", Value = fte.ToString("N1"), Detail = "Summe Beschaeftigungsgrad", Severity = "Normal" },
|
||||||
|
new() { Label = "Krankheitstage", Value = sickDays.ToString("N1"), Detail = $"Absenzquote {absenceRate:P1}", Severity = absenceRate > 0.05m ? "Warning" : "Normal" },
|
||||||
|
new() { Label = $"Fluktuation {year}", Value = turnover.ToString("P1"), Detail = $"{relevantLeavers:N0} relevant von {employeeLeavers:N0} AN-Kuendigungen", Severity = turnover > 0.12m ? "Warning" : "Normal" },
|
||||||
|
new() { Label = "GLZ Schnitt", Value = avgBalance.ToString("N1"), Detail = $"{redBalance:N0} Personen > 100h absolut", Severity = redBalance > 0 ? "Warning" : "Normal" },
|
||||||
|
new() { Label = "Unfalltage", Value = employees.Sum(x => x.BuTage + x.NbuTage).ToString("N1"), Detail = $"BU {employees.Sum(x => x.BuTage):N1} / NBU {employees.Sum(x => x.NbuTage):N1}", Severity = "Normal" }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<HrKpiMetric> BuildTurnoverMetrics(
|
||||||
|
IReadOnlyCollection<HrKpiEmployeeRow> employees,
|
||||||
|
IReadOnlyCollection<HrLeaverRow> leavers,
|
||||||
|
int year,
|
||||||
|
DateTime anchorDate)
|
||||||
|
{
|
||||||
|
var fixedHeadcount = CountDistinctPersons(employees
|
||||||
|
.Where(x => string.Equals(x.Mitarbeitertyp, "Festangestellt", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(x => x.Personalnummer));
|
||||||
|
var totalLeavers = CountDistinctPersons(leavers.Select(x => x.Personalnummer));
|
||||||
|
var employeeResignations = leavers
|
||||||
|
.Where(x => x.IstArbeitnehmerkuendigung)
|
||||||
|
.Select(x => x.Personalnummer)
|
||||||
|
.ToList();
|
||||||
|
var relevantLeavers = leavers
|
||||||
|
.Where(x => x.IstFluktuationsrelevant)
|
||||||
|
.Select(x => x.Personalnummer)
|
||||||
|
.ToList();
|
||||||
|
var nonRelevantLeavers = leavers
|
||||||
|
.Where(x => !x.IstFluktuationsrelevant)
|
||||||
|
.Select(x => x.Personalnummer)
|
||||||
|
.ToList();
|
||||||
|
var employeeResignationCount = CountDistinctPersons(employeeResignations);
|
||||||
|
var relevantLeaverCount = CountDistinctPersons(relevantLeavers);
|
||||||
|
var nonRelevantLeaverCount = CountDistinctPersons(nonRelevantLeavers);
|
||||||
|
|
||||||
|
var currentMonth = anchorDate.Month;
|
||||||
|
var currentQuarter = ((currentMonth - 1) / 3) + 1;
|
||||||
|
var quarterLeavers = leavers
|
||||||
|
.Where(x => x.IstFluktuationsrelevant &&
|
||||||
|
x.Austrittsdatum.HasValue &&
|
||||||
|
x.Austrittsdatum.Value.Year == year &&
|
||||||
|
((x.Austrittsdatum.Value.Month - 1) / 3) + 1 == currentQuarter)
|
||||||
|
.Select(x => x.Personalnummer)
|
||||||
|
.ToList();
|
||||||
|
var monthLeavers = leavers
|
||||||
|
.Where(x => x.IstFluktuationsrelevant &&
|
||||||
|
x.Austrittsdatum.HasValue &&
|
||||||
|
x.Austrittsdatum.Value.Year == year &&
|
||||||
|
x.Austrittsdatum.Value.Month == currentMonth)
|
||||||
|
.Select(x => x.Personalnummer)
|
||||||
|
.ToList();
|
||||||
|
var yearLeavers = leavers
|
||||||
|
.Where(x => x.IstFluktuationsrelevant && x.Austrittsjahr == year)
|
||||||
|
.Select(x => x.Personalnummer)
|
||||||
|
.ToList();
|
||||||
|
var quarterLeaverCount = CountDistinctPersons(quarterLeavers);
|
||||||
|
var monthLeaverCount = CountDistinctPersons(monthLeavers);
|
||||||
|
var yearLeaverCount = CountDistinctPersons(yearLeavers);
|
||||||
|
|
||||||
|
var monthRate = fixedHeadcount == 0 ? 0 : monthLeaverCount / (decimal)fixedHeadcount;
|
||||||
|
var quarterRate = fixedHeadcount == 0 ? 0 : quarterLeaverCount / (decimal)fixedHeadcount;
|
||||||
|
var forecastRate = quarterRate * 4;
|
||||||
|
var yearRate = fixedHeadcount == 0 ? 0 : yearLeaverCount / (decimal)fixedHeadcount;
|
||||||
|
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new() { Label = "Headcount Festangestellt", Value = fixedHeadcount.ToString("N0"), Detail = "Nenner fuer Fluktuation", Severity = "Normal" },
|
||||||
|
new() { Label = "Austritte Total Rexx", Value = totalLeavers.ToString("N0"), Detail = "Alle Austritte in Rexx", Severity = "Normal" },
|
||||||
|
new() { Label = "Austritte Arbeitnehmerkuendigung", Value = employeeResignationCount.ToString("N0"), Detail = "AN-/MA-Kuendigungen", Severity = "Normal" },
|
||||||
|
new() { Label = "Austritte Fluktuationsrelevant", Value = relevantLeaverCount.ToString("N0"), Detail = "Nach HR-Definition", Severity = "Normal" },
|
||||||
|
new() { Label = "Austritte Nicht relevant", Value = nonRelevantLeaverCount.ToString("N0"), Detail = "Ausgeschlossen oder unklar", Severity = nonRelevantLeaverCount > relevantLeaverCount ? "Warning" : "Normal" },
|
||||||
|
new() { Label = "Fluktuation Monat %", Value = monthRate.ToString("P1"), Detail = $"{monthLeaverCount:N0} Austritte im Monat", Severity = monthRate > 0.03m ? "Warning" : "Normal" },
|
||||||
|
new() { Label = "Avg Headcount Quartal", Value = fixedHeadcount.ToString("N0"), Detail = "Stichtagsdaten: entspricht aktuellem Headcount", Severity = "Normal" },
|
||||||
|
new() { Label = "Austritte Quartal", Value = quarterLeaverCount.ToString("N0"), Detail = $"Quartal {currentQuarter}/{year}", Severity = "Normal" },
|
||||||
|
new() { Label = "Fluktuation Quartal %", Value = quarterRate.ToString("P1"), Detail = "Austritte Quartal / Headcount", Severity = quarterRate > 0.08m ? "Warning" : "Normal" },
|
||||||
|
new() { Label = "Fluktuation Hochrechnung Jahr %", Value = forecastRate.ToString("P1"), Detail = "Quartalsrate x 4", Severity = forecastRate > 0.12m ? "Warning" : "Normal" },
|
||||||
|
new() { Label = "Avg Headcount Jahr", Value = fixedHeadcount.ToString("N0"), Detail = "Stichtagsdaten: entspricht aktuellem Headcount", Severity = "Normal" },
|
||||||
|
new() { Label = "Austritte Jahr", Value = yearLeaverCount.ToString("N0"), Detail = $"Fluktuationsrelevant {year}", Severity = "Normal" },
|
||||||
|
new() { Label = "Fluktuation Jahr Effektiv %", Value = yearRate.ToString("P1"), Detail = "Austritte Jahr / Headcount", Severity = yearRate > 0.12m ? "Warning" : "Normal" },
|
||||||
|
new() { Label = "Ausschlussgrund Anzahl", Value = totalLeavers.ToString("N0"), Detail = "Basis fuer Ausschlussgrund-Tabelle", Severity = "Normal" }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<HrKpiMetric> BuildAbsenceMetrics(
|
||||||
|
IReadOnlyCollection<HrKpiEmployeeRow> employees,
|
||||||
|
IReadOnlyCollection<HrAbsenceRow> absences)
|
||||||
|
{
|
||||||
|
var headcount = CountDistinctPersons(employees.Select(x => x.Personalnummer));
|
||||||
|
var totalSick = absences.Sum(x => x.KrankheitstageGesamt);
|
||||||
|
var shortSick = absences.Sum(x => x.KrankheitstageKurz);
|
||||||
|
var longSick = absences.Sum(x => x.KrankheitstageLang);
|
||||||
|
var absenceRate = headcount == 0 ? 0 : totalSick / (headcount * 21m);
|
||||||
|
var bu = employees.Sum(x => x.BuTage);
|
||||||
|
var nbu = employees.Sum(x => x.NbuTage);
|
||||||
|
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new() { Label = "Krankheitstage Gesamt", Value = totalSick.ToString("N1"), Detail = $"{absences.Count:N0} aktive Absenzenzeilen", Severity = absenceRate > 0.05m ? "Warning" : "Normal" },
|
||||||
|
new() { Label = "Krankheit Kurz", Value = shortSick.ToString("N1"), Detail = "Rexx kurz / 8.4h", Severity = "Normal" },
|
||||||
|
new() { Label = "Krankheit Lang", Value = longSick.ToString("N1"), Detail = "Rexx lang / 8.4h", Severity = longSick > shortSick ? "Warning" : "Normal" },
|
||||||
|
new() { Label = "Krankenquote", Value = absenceRate.ToString("P1"), Detail = "Krankheitstage / 21 Tage / Headcount", Severity = absenceRate > 0.05m ? "Warning" : "Normal" },
|
||||||
|
new() { Label = "BU-Tage", Value = bu.ToString("N1"), Detail = "SAP HR KPI", Severity = "Normal" },
|
||||||
|
new() { Label = "NBU-Tage", Value = nbu.ToString("N1"), Detail = "SAP HR KPI", Severity = "Normal" },
|
||||||
|
new() { Label = "Unfalltage Total", Value = (bu + nbu).ToString("N1"), Detail = "BU + NBU", Severity = "Normal" }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<HrKpiMetric> BuildTimeVacationMetrics(IReadOnlyCollection<HrKpiEmployeeRow> employees)
|
||||||
|
{
|
||||||
|
var headcount = employees.Count;
|
||||||
|
var avgBalance = headcount == 0 ? 0 : employees.Average(x => x.StundenSaldo);
|
||||||
|
var red = employees.Count(x => x.GlzAmpel == "Rot");
|
||||||
|
var yellow = employees.Count(x => x.GlzAmpel == "Gelb");
|
||||||
|
var vacationEntitlement = employees.Sum(x => x.Urlaubsanspruch);
|
||||||
|
var vacationUsed = employees.Sum(x => x.Ferientage);
|
||||||
|
var vacationLeft = employees.Sum(x => x.UrlaubRest);
|
||||||
|
var vacationOpen = employees.Sum(x => x.FerienAusstehend);
|
||||||
|
var restVacationRed = employees.Count(x => x.RestferienAmpel == "Rot");
|
||||||
|
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new() { Label = "GLZ-Saldo Schnitt", Value = avgBalance.ToString("N1"), Detail = "Stunden pro Mitarbeiter", Severity = Math.Abs(avgBalance) > 50 ? "Warning" : "Normal" },
|
||||||
|
new() { Label = "GLZ Gelb", Value = yellow.ToString("N0"), Detail = "51-100h absolut", Severity = yellow > 0 ? "Warning" : "Normal" },
|
||||||
|
new() { Label = "GLZ Rot", Value = red.ToString("N0"), Detail = ">100h absolut", Severity = red > 0 ? "Warning" : "Normal" },
|
||||||
|
new() { Label = "Ferienanspruch", Value = vacationEntitlement.ToString("N1"), Detail = "Summe Tage", Severity = "Normal" },
|
||||||
|
new() { Label = "Ferien bezogen", Value = vacationUsed.ToString("N1"), Detail = "Anspruch - Rest - ausstehend", Severity = "Normal" },
|
||||||
|
new() { Label = "Ferien Rest", Value = vacationLeft.ToString("N1"), Detail = "Rexx Urlaub Rest", Severity = restVacationRed > 0 ? "Warning" : "Normal" },
|
||||||
|
new() { Label = "Ferien ausstehend", Value = vacationOpen.ToString("N1"), Detail = "Rexx ausstehend", Severity = "Normal" },
|
||||||
|
new() { Label = "Restferien Rot", Value = restVacationRed.ToString("N0"), Detail = ">5 Tage Rest", Severity = restVacationRed > 0 ? "Warning" : "Normal" }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HrTurnoverVisuals BuildTurnoverVisuals(
|
||||||
|
IReadOnlyCollection<HrKpiEmployeeRow> employees,
|
||||||
|
IReadOnlyCollection<HrLeaverRow> leavers,
|
||||||
|
int year)
|
||||||
|
{
|
||||||
|
var fixedHeadcount = CountDistinctPersons(employees
|
||||||
|
.Where(x => string.Equals(x.Mitarbeitertyp, "Festangestellt", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(x => x.Personalnummer));
|
||||||
|
var totalLeavers = CountDistinctPersons(leavers.Select(x => x.Personalnummer));
|
||||||
|
var employeeResignations = CountDistinctPersons(leavers.Where(x => x.IstArbeitnehmerkuendigung).Select(x => x.Personalnummer));
|
||||||
|
var relevantLeavers = CountDistinctPersons(leavers.Where(x => x.IstFluktuationsrelevant).Select(x => x.Personalnummer));
|
||||||
|
var notRelevant = Math.Max(0, totalLeavers - relevantLeavers);
|
||||||
|
var ratePercent = fixedHeadcount == 0 ? 0 : relevantLeavers / (decimal)fixedHeadcount * 100m;
|
||||||
|
var gaugeColor = ratePercent > 12m ? "#c62828" : ratePercent >= 8m ? "#f9a825" : "#2e7d32";
|
||||||
|
|
||||||
|
var maxFunnel = Math.Max(totalLeavers, 1);
|
||||||
|
var reasonColors = new[] { "#455a64", "#7b1fa2", "#0277bd", "#ef6c00", "#8d6e63", "#ad1457", "#558b2f" };
|
||||||
|
var reasons = leavers
|
||||||
|
.GroupBy(x => x.FluktuationAusschlussgrund ?? "Fluktuationsrelevant", StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select((g, index) => new HrKpiGroupValue
|
||||||
|
{
|
||||||
|
Label = g.Key,
|
||||||
|
Count = g.Count(),
|
||||||
|
Value = g.Count(),
|
||||||
|
Percent = totalLeavers == 0 ? 0 : g.Count() / (decimal)totalLeavers * 100m,
|
||||||
|
Color = reasonColors[index % reasonColors.Length]
|
||||||
|
})
|
||||||
|
.OrderByDescending(x => x.Count)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var relevantByOrg = leavers
|
||||||
|
.Where(x => x.IstFluktuationsrelevant)
|
||||||
|
.GroupBy(x => BlankAsUnknown(x.Organisationseinheit), StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(g => new HrKpiGroupValue
|
||||||
|
{
|
||||||
|
Label = g.Key,
|
||||||
|
Count = g.Count(),
|
||||||
|
Value = g.Count(),
|
||||||
|
Percent = relevantLeavers == 0 ? 0 : g.Count() / (decimal)relevantLeavers * 100m,
|
||||||
|
Color = "#1565c0"
|
||||||
|
})
|
||||||
|
.OrderByDescending(x => x.Count)
|
||||||
|
.ThenBy(x => x.Label, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Take(10)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var monthly = Enumerable.Range(1, 12)
|
||||||
|
.Select(month =>
|
||||||
|
{
|
||||||
|
var count = leavers.Count(x =>
|
||||||
|
x.IstFluktuationsrelevant &&
|
||||||
|
x.Austrittsdatum.HasValue &&
|
||||||
|
x.Austrittsdatum.Value.Year == year &&
|
||||||
|
x.Austrittsdatum.Value.Month == month);
|
||||||
|
return new HrKpiGroupValue
|
||||||
|
{
|
||||||
|
Label = CultureInfo.GetCultureInfo("de-CH").DateTimeFormat.GetAbbreviatedMonthName(month),
|
||||||
|
Count = count,
|
||||||
|
Value = count,
|
||||||
|
Percent = relevantLeavers == 0 ? 0 : count / (decimal)relevantLeavers * 100m,
|
||||||
|
Color = "#00897b"
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new HrTurnoverVisuals
|
||||||
|
{
|
||||||
|
YearRatePercent = ratePercent,
|
||||||
|
YearRateLabel = (ratePercent / 100m).ToString("P1"),
|
||||||
|
GaugeColor = gaugeColor,
|
||||||
|
GaugeRotationDegrees = Math.Clamp(ratePercent / 20m, 0m, 1m) * 180m,
|
||||||
|
FunnelSteps =
|
||||||
|
[
|
||||||
|
new() { Label = "Austritte Total", Count = totalLeavers, Value = totalLeavers, Percent = 100m, Color = "#546e7a" },
|
||||||
|
new() { Label = "Arbeitnehmerkuendigungen", Count = employeeResignations, Value = employeeResignations, Percent = employeeResignations / (decimal)maxFunnel * 100m, Color = "#1976d2" },
|
||||||
|
new() { Label = "Fluktuationsrelevant", Count = relevantLeavers, Value = relevantLeavers, Percent = relevantLeavers / (decimal)maxFunnel * 100m, Color = "#2e7d32" },
|
||||||
|
new() { Label = "Nicht relevant", Count = notRelevant, Value = notRelevant, Percent = notRelevant / (decimal)maxFunnel * 100m, Color = "#8d6e63" }
|
||||||
|
],
|
||||||
|
ExclusionReasons = reasons,
|
||||||
|
RelevantByOrganisation = relevantByOrg,
|
||||||
|
MonthlyRelevantLeavers = monthly
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeFilter(string? value)
|
||||||
|
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
private static int ResolveAnalysisYear(HrKpiOptions options)
|
||||||
|
=> (options.ToDate ?? options.FromDate)?.Year ?? options.Year;
|
||||||
|
|
||||||
|
private static DateTime ResolveTurnoverAnchorDate(HrKpiOptions options, int analysisYear)
|
||||||
|
{
|
||||||
|
if (options.ToDate.HasValue)
|
||||||
|
return options.ToDate.Value.Date;
|
||||||
|
if (options.FromDate.HasValue)
|
||||||
|
return options.FromDate.Value.Date;
|
||||||
|
return analysisYear == DateTime.Today.Year
|
||||||
|
? DateTime.Today
|
||||||
|
: new DateTime(analysisYear, 12, 31);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesLeaverDateFilter(HrLeaverRow row, HrKpiOptions options)
|
||||||
|
{
|
||||||
|
var hasRange = options.FromDate.HasValue || options.ToDate.HasValue;
|
||||||
|
if (hasRange)
|
||||||
|
{
|
||||||
|
if (!row.Austrittsdatum.HasValue)
|
||||||
|
return false;
|
||||||
|
return (!options.FromDate.HasValue || row.Austrittsdatum.Value.Date >= options.FromDate.Value) &&
|
||||||
|
(!options.ToDate.HasValue || row.Austrittsdatum.Value.Date <= options.ToDate.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return row.Austrittsjahr.HasValue && row.Austrittsjahr.Value == options.Year;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountDistinctPersons(IEnumerable<int?> personalNumbers)
|
||||||
|
=> personalNumbers
|
||||||
|
.Where(x => x.HasValue)
|
||||||
|
.Select(x => x!.Value)
|
||||||
|
.Distinct()
|
||||||
|
.Count();
|
||||||
|
|
||||||
|
private static decimal ResolveFte(decimal? employmentPercent, string workingTimeModel, decimal? averageHoursPerDay)
|
||||||
|
{
|
||||||
|
if (employmentPercent.HasValue && employmentPercent.Value > 0)
|
||||||
|
return employmentPercent.Value / 100m;
|
||||||
|
|
||||||
|
if (averageHoursPerDay.HasValue && averageHoursPerDay.Value > 0)
|
||||||
|
return Math.Clamp(averageHoursPerDay.Value / 8.4m, 0.1m, 1.2m);
|
||||||
|
|
||||||
|
if (string.Equals(workingTimeModel, "Vollzeit", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return 1m;
|
||||||
|
|
||||||
|
if (string.Equals(workingTimeModel, "Teilzeit", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return 0.5m;
|
||||||
|
|
||||||
|
return 0m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesFilter(string value, string? filter)
|
||||||
|
=> string.IsNullOrWhiteSpace(filter) || string.Equals(value?.Trim(), filter, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static bool MatchesEmployeeSearch(HrKpiEmployeeRow row, string? search)
|
||||||
|
=> MatchesTextSearch(search, row.NameVoll, row.Personalnummer?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, row.Organisationseinheit, row.KostenstelleText, row.Stelle);
|
||||||
|
|
||||||
|
private static bool MatchesTextSearch(string? search, params string[] values)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(search))
|
||||||
|
return true;
|
||||||
|
return values.Any(value => value.Contains(search, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesFluctuationFilter(HrLeaverRow row, string? filter)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(filter) || string.Equals(filter, "Alle", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return true;
|
||||||
|
if (string.Equals(filter, "Fluktuationsrelevant", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return row.IstFluktuationsrelevant;
|
||||||
|
if (string.Equals(filter, "Arbeitnehmerkuendigung", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return row.IstArbeitnehmerkuendigung;
|
||||||
|
if (string.Equals(filter, "Ausgeschlossen", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return !row.IstFluktuationsrelevant;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BlankAsUnknown(string value)
|
||||||
|
=> string.IsNullOrWhiteSpace(value) ? "Unbekannt" : value;
|
||||||
|
|
||||||
|
private static string BuildPersonalKey(int? personalnummer)
|
||||||
|
=> personalnummer?.ToString(CultureInfo.InvariantCulture) ?? string.Empty;
|
||||||
|
|
||||||
|
private static string NormalizeKey(string value)
|
||||||
|
=> value.Trim().ToUpperInvariant();
|
||||||
|
|
||||||
|
private static int? ParseCostCenter(string value)
|
||||||
|
{
|
||||||
|
var raw = value.Split('/')[0].Trim();
|
||||||
|
return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string Nachname, string Vorname) SplitName(string value)
|
||||||
|
{
|
||||||
|
var parts = value.Split(',', 2, StringSplitOptions.TrimEntries);
|
||||||
|
return parts.Length == 2 ? (parts[0], parts[1]) : (value.Trim(), string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? YearsSince(DateTime? date)
|
||||||
|
{
|
||||||
|
if (!date.HasValue)
|
||||||
|
return null;
|
||||||
|
var today = DateTime.Today;
|
||||||
|
var years = today.Year - date.Value.Year;
|
||||||
|
if (date.Value.Date > today.AddYears(-years))
|
||||||
|
years--;
|
||||||
|
return years;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildAgeGroup(int? age)
|
||||||
|
{
|
||||||
|
if (!age.HasValue) return "Unbekannt";
|
||||||
|
if (age.Value < 30) return "< 30";
|
||||||
|
if (age.Value < 40) return "30-39";
|
||||||
|
if (age.Value < 50) return "40-49";
|
||||||
|
if (age.Value < 60) return "50-59";
|
||||||
|
return "60+";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string MapGender(int? value)
|
||||||
|
=> value switch
|
||||||
|
{
|
||||||
|
1 => "Maennlich",
|
||||||
|
2 => "Weiblich",
|
||||||
|
_ => "Unbekannt"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static decimal ParseTimeBalance(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
return 0;
|
||||||
|
var trimmed = value.Trim();
|
||||||
|
var negative = trimmed.StartsWith("-", StringComparison.Ordinal);
|
||||||
|
trimmed = trimmed.TrimStart('-');
|
||||||
|
var parts = trimmed.Split(':');
|
||||||
|
if (parts.Length == 0)
|
||||||
|
return 0;
|
||||||
|
var hours = ParseDecimal(parts[0]);
|
||||||
|
var minutes = parts.Length > 1 ? ParseDecimal(parts[1]) : 0;
|
||||||
|
var result = hours + minutes / 60m;
|
||||||
|
return negative ? -result : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildTrafficLight(decimal balance)
|
||||||
|
{
|
||||||
|
var absolute = Math.Abs(balance);
|
||||||
|
if (absolute <= 50) return "Gruen";
|
||||||
|
return absolute <= 100 ? "Gelb" : "Rot";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildEmployeeType(string position)
|
||||||
|
{
|
||||||
|
var lower = NormalizeReason(position);
|
||||||
|
if (lower.Contains("praktik", StringComparison.OrdinalIgnoreCase)) return "Praktikant";
|
||||||
|
if (lower.Contains("werkstudent", StringComparison.OrdinalIgnoreCase)) return "Werkstudent";
|
||||||
|
if (lower.Contains("aushilfe", StringComparison.OrdinalIgnoreCase)) return "Aushilfe";
|
||||||
|
if (lower.Contains("lehrling", StringComparison.OrdinalIgnoreCase)) return "Lehrling";
|
||||||
|
return "Festangestellt";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeReason(string value)
|
||||||
|
{
|
||||||
|
var normalized = RemoveDiacritics(value).Trim().ToLowerInvariant();
|
||||||
|
return normalized
|
||||||
|
.Replace("ä", "ae", StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Replace("ö", "oe", StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Replace("ü", "ue", StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Replace("ß", "ss", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildExclusionReason(string employeeType, string reason, bool isEmployeeResignation)
|
||||||
|
{
|
||||||
|
if (!string.Equals(employeeType, "Festangestellt", StringComparison.OrdinalIgnoreCase)) return employeeType;
|
||||||
|
if (string.IsNullOrWhiteSpace(reason)) return "Austrittsart leer/unklar";
|
||||||
|
if (reason.Contains("befrist", StringComparison.OrdinalIgnoreCase)) return "Befristeter Vertrag";
|
||||||
|
if (reason.Contains("pension", StringComparison.OrdinalIgnoreCase) || reason.Contains("rente", StringComparison.OrdinalIgnoreCase)) return "Pensionierung";
|
||||||
|
if (reason.Contains("trafag", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
reason.Contains("arbeitgeber", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
reason.Contains("ag-kuendigung", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
reason.Contains("ag kuendigung", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
reason.Contains("kuendigung ag", StringComparison.OrdinalIgnoreCase)) return "Kuendigung durch Trafag";
|
||||||
|
return isEmployeeResignation ? "Ausgeschlossen" : "Keine Arbeitnehmerkuendigung";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadString(IXLRow row, IReadOnlyDictionary<string, int> headers, params string[] aliases)
|
||||||
|
{
|
||||||
|
var index = FindHeader(headers, aliases);
|
||||||
|
return index.HasValue ? row.Cell(index.Value).GetFormattedString().Trim() : string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? ReadInt(IXLRow row, IReadOnlyDictionary<string, int> headers, params string[] aliases)
|
||||||
|
{
|
||||||
|
var value = ReadString(row, headers, aliases);
|
||||||
|
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
|
||||||
|
return parsed;
|
||||||
|
var decimalValue = ParseDecimalNullable(value);
|
||||||
|
return decimalValue.HasValue ? (int)Math.Truncate(decimalValue.Value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal ReadDecimal(IXLRow row, IReadOnlyDictionary<string, int> headers, params string[] aliases)
|
||||||
|
=> ReadDecimalNullable(row, headers, aliases) ?? 0;
|
||||||
|
|
||||||
|
private static decimal? ReadDecimalNullable(IXLRow row, IReadOnlyDictionary<string, int> headers, params string[] aliases)
|
||||||
|
{
|
||||||
|
var index = FindHeader(headers, aliases);
|
||||||
|
if (!index.HasValue)
|
||||||
|
return null;
|
||||||
|
var cell = row.Cell(index.Value);
|
||||||
|
if (cell.TryGetValue<decimal>(out var decimalValue))
|
||||||
|
return decimalValue;
|
||||||
|
if (cell.TryGetValue<double>(out var doubleValue))
|
||||||
|
return (decimal)doubleValue;
|
||||||
|
return ParseDecimalNullable(cell.GetFormattedString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime? ReadDate(IXLRow row, IReadOnlyDictionary<string, int> headers, params string[] aliases)
|
||||||
|
{
|
||||||
|
var index = FindHeader(headers, aliases);
|
||||||
|
if (!index.HasValue)
|
||||||
|
return null;
|
||||||
|
var cell = row.Cell(index.Value);
|
||||||
|
if (cell.TryGetValue<DateTime>(out var dateValue))
|
||||||
|
return dateValue.Date;
|
||||||
|
if (cell.TryGetValue<double>(out var serialValue))
|
||||||
|
return DateTime.FromOADate(serialValue).Date;
|
||||||
|
var value = cell.GetFormattedString().Trim();
|
||||||
|
if (DateTime.TryParse(value, CultureInfo.GetCultureInfo("de-CH"), DateTimeStyles.None, out var parsed) ||
|
||||||
|
DateTime.TryParse(value, CultureInfo.GetCultureInfo("de-DE"), DateTimeStyles.None, out parsed) ||
|
||||||
|
DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out parsed))
|
||||||
|
return parsed.Date;
|
||||||
|
if (double.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out serialValue))
|
||||||
|
return DateTime.FromOADate(serialValue).Date;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? FindHeader(IReadOnlyDictionary<string, int> headers, params string[] aliases)
|
||||||
|
{
|
||||||
|
foreach (var alias in aliases)
|
||||||
|
{
|
||||||
|
if (headers.TryGetValue(NormalizeHeader(alias), out var index))
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal ParseDecimal(string value)
|
||||||
|
=> ParseDecimalNullable(value) ?? 0;
|
||||||
|
|
||||||
|
private static decimal? ParseDecimalNullable(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
return null;
|
||||||
|
var normalized = value.Trim()
|
||||||
|
.Replace("'", "", StringComparison.Ordinal)
|
||||||
|
.Replace(" ", "", StringComparison.Ordinal);
|
||||||
|
if (decimal.TryParse(normalized, NumberStyles.Number, CultureInfo.GetCultureInfo("de-CH"), out var result) ||
|
||||||
|
decimal.TryParse(normalized, NumberStyles.Number, CultureInfo.GetCultureInfo("de-DE"), out result) ||
|
||||||
|
decimal.TryParse(normalized, NumberStyles.Number, CultureInfo.InvariantCulture, out result))
|
||||||
|
return result;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeHeader(string value)
|
||||||
|
{
|
||||||
|
var normalized = RemoveDiacritics(value)
|
||||||
|
.Replace("ü", "u", StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Replace("ä", "a", StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Replace("ö", "o", StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Replace("Ø", "o", StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Replace("ø", "o", StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Replace("Ø", "o", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var builder = new StringBuilder(normalized.Length);
|
||||||
|
foreach (var ch in normalized.ToLowerInvariant())
|
||||||
|
{
|
||||||
|
if (char.IsLetterOrDigit(ch))
|
||||||
|
builder.Append(ch);
|
||||||
|
}
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RemoveDiacritics(string value)
|
||||||
|
{
|
||||||
|
var normalized = value.Normalize(NormalizationForm.FormD);
|
||||||
|
var builder = new StringBuilder(normalized.Length);
|
||||||
|
foreach (var ch in normalized)
|
||||||
|
{
|
||||||
|
if (CharUnicodeInfo.GetUnicodeCategory(ch) != UnicodeCategory.NonSpacingMark)
|
||||||
|
builder.Append(ch);
|
||||||
|
}
|
||||||
|
return builder.ToString().Normalize(NormalizationForm.FormC);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record TimeRow(string NameKey, DateTime? Geburtsdatum, string Arbeitszeitmodell, decimal AvgSollzeitTag);
|
||||||
|
|
||||||
|
private sealed record SapRow(
|
||||||
|
string PersonalKey,
|
||||||
|
string Buchungskreis,
|
||||||
|
string Personalbereich,
|
||||||
|
string Personalteilbereich,
|
||||||
|
string Mitarbeitergruppe,
|
||||||
|
string Mitarbeiterkreis,
|
||||||
|
string Teilzeitkennzeichen,
|
||||||
|
decimal? BeschaeftigungsgradProzent,
|
||||||
|
int? Geschlecht,
|
||||||
|
string Planstelle,
|
||||||
|
string SollStelle,
|
||||||
|
decimal NbuTage,
|
||||||
|
decimal BuTage,
|
||||||
|
string Abrechnungskreis);
|
||||||
|
|
||||||
|
private sealed class ImportContext
|
||||||
|
{
|
||||||
|
private readonly HrKpiResult _result;
|
||||||
|
private readonly string _folder;
|
||||||
|
|
||||||
|
public ImportContext(HrKpiResult result, string folder)
|
||||||
|
{
|
||||||
|
_result = result;
|
||||||
|
_folder = folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasFile(string fileName)
|
||||||
|
=> File.Exists(BuildPath(fileName));
|
||||||
|
|
||||||
|
public List<T> ReadRows<T>(string fileName, string label, Func<IXLRow, IReadOnlyDictionary<string, int>, T> map)
|
||||||
|
{
|
||||||
|
var path = BuildPath(fileName);
|
||||||
|
var status = new HrKpiFileStatus
|
||||||
|
{
|
||||||
|
Label = label,
|
||||||
|
Path = path,
|
||||||
|
Exists = File.Exists(path)
|
||||||
|
};
|
||||||
|
_result.FileStatuses.Add(status);
|
||||||
|
|
||||||
|
if (!status.Exists)
|
||||||
|
{
|
||||||
|
status.Message = "Datei nicht gefunden";
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var workbook = new XLWorkbook(path);
|
||||||
|
var worksheet = workbook.Worksheets.First();
|
||||||
|
var headerRow = worksheet.FirstRowUsed();
|
||||||
|
if (headerRow is null)
|
||||||
|
{
|
||||||
|
status.Message = "Leeres Arbeitsblatt";
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var headers = headerRow.CellsUsed()
|
||||||
|
.GroupBy(c => NormalizeHeader(c.GetString()))
|
||||||
|
.Where(g => !string.IsNullOrWhiteSpace(g.Key))
|
||||||
|
.ToDictionary(g => g.Key, g => g.First().Address.ColumnNumber, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var rows = worksheet.RowsUsed()
|
||||||
|
.Where(r => r.RowNumber() > headerRow.RowNumber())
|
||||||
|
.Where(r => !r.CellsUsed().All(c => string.IsNullOrWhiteSpace(c.GetFormattedString())))
|
||||||
|
.Select(r => map(r, headers))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
status.RowCount = rows.Count;
|
||||||
|
status.Message = "OK";
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
status.Message = ex.Message;
|
||||||
|
_result.Notices.Add($"{label}: {ex.Message}");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildPath(string fileName)
|
||||||
|
=> Path.Combine(_folder, fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,315 @@
|
|||||||
|
# HR-KPI-Pruefung gegen Schweizer Praxis und HR-Best-Practices
|
||||||
|
|
||||||
|
Stand: 2026-05-13
|
||||||
|
|
||||||
|
Zweck dieses Dokuments:
|
||||||
|
|
||||||
|
- fachliche Pruefpunkte fuer den neuen Reiter `HR KPI` sammeln
|
||||||
|
- keine Codeaenderung ausloesen
|
||||||
|
- sichtbar machen, welche Kennzahlen bereits plausibel sind und wo vor produktiver Nutzung noch HR-/Fachentscheid noetig ist
|
||||||
|
|
||||||
|
## Quellen und Massstab
|
||||||
|
|
||||||
|
Verwendeter Massstab:
|
||||||
|
|
||||||
|
- Schweizer Absenzverstaendnis gemaess BFS/AVOL: Absenzen sind Zeiten, in denen eine Person normalerweise haette arbeiten muessen, aber nicht gearbeitet hat. Ferien/Feiertage und flexible Arbeitszeitreduktionen sind keine Absenzen.
|
||||||
|
- Obsan/BFS-Definition fuer gesundheitsbedingte Absenzen: Krankheit und Unfall; Absenzenquote = Absenzen als Prozent der vertraglich festgelegten Jahresarbeitszeit.
|
||||||
|
- Internationale HR-Controlling-Praxis fuer Fluktuation: Austritte im Zeitraum geteilt durch durchschnittlichen Headcount im Zeitraum. Freiwillige und unfreiwillige Austritte sollten getrennt ausgewiesen werden.
|
||||||
|
|
||||||
|
Referenzen:
|
||||||
|
|
||||||
|
- BFS Arbeitsvolumenstatistik / Definitionen: https://www.bfs.admin.ch/bfs/de/home/statistiken/arbeit-erwerb/erhebungen/avol.html
|
||||||
|
- Obsan/BFS Absenzen Krankheit/Unfall: https://ind.obsan.admin.ch/de/indicator/pflemo/absenzen-durch-krankheitunfall
|
||||||
|
- BAG/Obsan MonAM Absenzen Krankheit/Unfall: https://ind.obsan.admin.ch/fr/indicator/monam/absences-au-travail-pour-cause-de-maladie-ou-d-accident-age-15
|
||||||
|
- CIPD Retention/Turnover Guidance: https://www.cipd.org/en/knowledge/guides/employee-retention/
|
||||||
|
- SHRM-nahe Turnover-Formel, oeffentlich referenziert: Separations / Average Employees * 100
|
||||||
|
|
||||||
|
## Aktueller Umsetzungsstand im Reiter
|
||||||
|
|
||||||
|
Der Reiter liest aktuell:
|
||||||
|
|
||||||
|
- `C:\temp\Saldiperstichdatum.xlsx` als Hauptquelle Rexx #757
|
||||||
|
- `C:\temp\Exportkommengehen.xlsx` fuer Geburtsdatum / Arbeitszeitmodell
|
||||||
|
- `C:\temp\HR_KPI_Export.xlsx` fuer SAP-Felder
|
||||||
|
- `C:\temp\Abwesenheitinstunden.xlsx` fuer Krankheit/Absenzen aus Rexx #744
|
||||||
|
- `C:\temp\Personalausgeschieden.xlsx` fuer Austritte/Fluktuation aus Rexx #381
|
||||||
|
|
||||||
|
Die Power-Query-/DAX-Logik wurde nicht als Interpreter umgesetzt, sondern als C#-Nachbau.
|
||||||
|
|
||||||
|
## Pruefpunkte mit moeglicher Abweichung
|
||||||
|
|
||||||
|
### 1. Fluktuationsnenner: Stichtags-Headcount statt Durchschnitt
|
||||||
|
|
||||||
|
Aktueller Reiter:
|
||||||
|
|
||||||
|
- `Headcount Festangestellt` wird aus dem aktuell geladenen Stichtagsbestand gerechnet.
|
||||||
|
- `Avg Headcount Quartal` und `Avg Headcount Jahr` entsprechen aktuell faktisch ebenfalls diesem Stichtagswert.
|
||||||
|
|
||||||
|
Best Practice:
|
||||||
|
|
||||||
|
- Fluktuation sollte fuer Monat, Quartal und Jahr mit durchschnittlichem Headcount des jeweiligen Zeitraums gerechnet werden.
|
||||||
|
- Bei stabiler Belegschaft ist der Unterschied klein.
|
||||||
|
- Bei Wachstum, Abbau oder saisonalen Schwankungen kann der Unterschied relevant sein.
|
||||||
|
|
||||||
|
Pruefen:
|
||||||
|
|
||||||
|
- Liefert Rexx/SAP monatliche Headcount-Snapshots?
|
||||||
|
- Falls ja: Monatsdurchschnitt fuer Quartal/Jahr berechnen.
|
||||||
|
- Falls nein: UI klar als `Stichtagsnahe Fluktuation` oder `Naeherung` beschriften.
|
||||||
|
|
||||||
|
Status:
|
||||||
|
|
||||||
|
- fachlich akzeptabel als erste Naeherung
|
||||||
|
- fuer offizielles HR-Reporting noch zu bestaetigen
|
||||||
|
|
||||||
|
### 2. Freiwillige vs. unfreiwillige Austritte
|
||||||
|
|
||||||
|
Aktueller Reiter:
|
||||||
|
|
||||||
|
- `Ist_Arbeitnehmerkuendigung` versucht freiwillige Arbeitnehmer-/Mitarbeiterkuendigungen anhand Textmustern zu erkennen.
|
||||||
|
- Praktikanten, Werkstudenten, Aushilfen, Lehrlinge, Pensionierungen, befristete Vertraege und Kuendigungen durch Trafag werden ausgeschlossen.
|
||||||
|
|
||||||
|
Best Practice:
|
||||||
|
|
||||||
|
- Total Turnover und Voluntary Turnover getrennt ausweisen.
|
||||||
|
- Fuer Retention ist freiwillige Fluktuation meist entscheidender als Gesamtaustritte.
|
||||||
|
|
||||||
|
Pruefen:
|
||||||
|
|
||||||
|
- Sind alle Rexx-Austrittsarten stabil und vollstaendig gemappt?
|
||||||
|
- Gibt es lokale Schreibweisen wie `Kdg AN`, `Eigenkuendigung`, `Aufhebungsvereinbarung`, `Mutual agreement`, `Ende Probezeit`?
|
||||||
|
- Soll `Aufhebungsvereinbarung` zaehlen oder separat ausgewiesen werden?
|
||||||
|
|
||||||
|
Status:
|
||||||
|
|
||||||
|
- HR-gepruefte Grundlogik vorhanden
|
||||||
|
- Mappingliste muss bei neuen Austrittsarten gepflegt/validiert werden
|
||||||
|
|
||||||
|
### 3. Fluktuation Quartal/Jahr bei nur einem aktuellen Bestand
|
||||||
|
|
||||||
|
Aktueller Reiter:
|
||||||
|
|
||||||
|
- Quartals-/Jahresraten werden ueber Austrittsdatum gefiltert.
|
||||||
|
- Headcount bleibt aktueller Stichtagsbestand.
|
||||||
|
|
||||||
|
Risiko:
|
||||||
|
|
||||||
|
- Wenn der aktuelle Bestand z. B. Ende Jahr niedriger/hoeher ist als im Quartal, verzerrt das die historische Rate.
|
||||||
|
|
||||||
|
Pruefen:
|
||||||
|
|
||||||
|
- Fuer Quartal/Jahr entweder echte historische Headcounts laden oder die Kennzahl explizit als operative Naeherung fuehren.
|
||||||
|
|
||||||
|
Status:
|
||||||
|
|
||||||
|
- Darstellung gut fuer operatives Cockpit
|
||||||
|
- nicht automatisch als auditierbare Jahreskennzahl verwenden
|
||||||
|
|
||||||
|
### 4. Absenzenquote: 21 Arbeitstage pauschal
|
||||||
|
|
||||||
|
Aktueller Reiter:
|
||||||
|
|
||||||
|
- Krankheitstage = Stunden / 8.4
|
||||||
|
- Krankenquote je Mitarbeiter = Krankheitstage / 21
|
||||||
|
- Gesamtquote = Krankheitstage / (Headcount * 21)
|
||||||
|
|
||||||
|
Schweizer/BFS-nahe Praxis:
|
||||||
|
|
||||||
|
- Absenzenquote wird als Dauer der Absenzen in Prozent der vertraglich festgelegten Arbeitszeit berechnet.
|
||||||
|
- Bei Teilzeit und unterschiedlichen Sollzeiten sollte der Nenner aus Sollarbeitszeit/Solltagen kommen.
|
||||||
|
|
||||||
|
Pruefen:
|
||||||
|
|
||||||
|
- Soll der Nenner pro Person aus `Avg_Sollzeit_Tag`, Arbeitszeitmodell oder Beschaeftigungsgrad berechnet werden?
|
||||||
|
- Fuer Teilzeit nicht pauschal 21 Vollzeittage verwenden, falls die Quote offiziell sein soll.
|
||||||
|
- Krankheit und Unfall separat ausweisen, wenn Datenquelle das erlaubt.
|
||||||
|
|
||||||
|
Status:
|
||||||
|
|
||||||
|
- 21-Tage-Naeherung gut fuer schnelle Sicht
|
||||||
|
- fuer Schweizer Standard-Absenzquote fachlich zu ungenau
|
||||||
|
|
||||||
|
### 5. Krankheit kurz/lang Definition
|
||||||
|
|
||||||
|
Aktueller Reiter:
|
||||||
|
|
||||||
|
- `Krankheit angetreten` = kurz
|
||||||
|
- `Krank nicht buchbar angetreten` = lang
|
||||||
|
- Umrechnung pauschal Stunden / 8.4
|
||||||
|
|
||||||
|
Pruefen:
|
||||||
|
|
||||||
|
- Bedeutet `Krank nicht buchbar` fachlich wirklich Langzeitkrankheit?
|
||||||
|
- Oder ist es ein Buchungs-/Workflowstatus?
|
||||||
|
- HR muss bestaetigen, ob diese Felder Kurz-/Langzeitkrankheit abbilden.
|
||||||
|
|
||||||
|
Status:
|
||||||
|
|
||||||
|
- benoetigt HR-/Rexx-Felddefinition
|
||||||
|
|
||||||
|
### 6. Unfalltage aus SAP vs. Rexx-Absenzen
|
||||||
|
|
||||||
|
Aktueller Reiter:
|
||||||
|
|
||||||
|
- Krankheit kommt aus Rexx-Stunden.
|
||||||
|
- BU/NBU kommt aus SAP-HR-KPI-Datei.
|
||||||
|
|
||||||
|
Pruefen:
|
||||||
|
|
||||||
|
- Sind BU/NBU in SAP und Krankheit in Rexx zeitlich gleich abgegrenzt?
|
||||||
|
- Sind Unfalltage in den Rexx-Krankheitsstunden enthalten oder getrennt?
|
||||||
|
- Gibt es Doppelzaehlung, wenn Krankheit/Unfall spaeter zusammengefuehrt werden?
|
||||||
|
|
||||||
|
Status:
|
||||||
|
|
||||||
|
- getrennte Anzeige ist korrekt
|
||||||
|
- Gesamtabsenzquote aus Krankheit + Unfall erst nach Quellenabgleich bilden
|
||||||
|
|
||||||
|
### 7. FTE-Berechnung
|
||||||
|
|
||||||
|
Aktueller Reiter:
|
||||||
|
|
||||||
|
- FTE = Beschaeftigungsgrad aus SAP / 100.
|
||||||
|
- Wenn SAP-Wert fehlt: Vollzeit = 1, sonst 0.5.
|
||||||
|
|
||||||
|
Best Practice:
|
||||||
|
|
||||||
|
- FTE sollte aus vertraglichem Beschaeftigungsgrad oder Sollarbeitszeit pro Person kommen.
|
||||||
|
- Pauschal 0.5 fuer Nicht-Vollzeit ist nur Fallback.
|
||||||
|
|
||||||
|
Pruefen:
|
||||||
|
|
||||||
|
- Ist `Beschaeftigungsgrad %` fuer alle aktiven Mitarbeitenden verfuegbar?
|
||||||
|
- Wenn nein: kann Rexx `Arbeitszeitmodell` oder Sollzeit genauer liefern?
|
||||||
|
|
||||||
|
Status:
|
||||||
|
|
||||||
|
- korrekt, wenn SAP-Datei vollstaendig ist
|
||||||
|
- Fallback fuer offizielle FTE zu grob
|
||||||
|
|
||||||
|
### 8. GLZ-Ampel 50/100 Stunden
|
||||||
|
|
||||||
|
Aktueller Reiter:
|
||||||
|
|
||||||
|
- Gruen: absolut <= 50h
|
||||||
|
- Gelb: absolut <= 100h
|
||||||
|
- Rot: absolut > 100h
|
||||||
|
|
||||||
|
Pruefen:
|
||||||
|
|
||||||
|
- Sind diese Schwellen HR-/GL-/Reglement-konform?
|
||||||
|
- Soll negative GLZ gleich behandelt werden wie positive?
|
||||||
|
- Gibt es unterschiedliche Regeln fuer Teilzeit?
|
||||||
|
|
||||||
|
Status:
|
||||||
|
|
||||||
|
- als Management-Ampel plausibel
|
||||||
|
- Schwellen fachlich bestaetigen lassen
|
||||||
|
|
||||||
|
### 9. Ferien-Rest-Ampel
|
||||||
|
|
||||||
|
Aktueller Reiter:
|
||||||
|
|
||||||
|
- Restferien <= 5 Tage = Gruen
|
||||||
|
- > 5 Tage = Rot
|
||||||
|
|
||||||
|
Pruefen:
|
||||||
|
|
||||||
|
- Ist >5 Tage wirklich kritisch oder nur zum Jahresende relevant?
|
||||||
|
- Soll der Stichtag im Jahr beruecksichtigt werden?
|
||||||
|
- Soll Anspruch, bezogen, ausstehend und Rest getrennt nach Kalenderjahr gezeigt werden?
|
||||||
|
|
||||||
|
Status:
|
||||||
|
|
||||||
|
- sehr grobe Ampel
|
||||||
|
- saisonale Logik fehlt
|
||||||
|
|
||||||
|
### 10. Lohn / Datenschutz
|
||||||
|
|
||||||
|
Aktueller Reiter:
|
||||||
|
|
||||||
|
- Bruttolohn wird im Model geladen, aber aktuell nicht prominent als KPI angezeigt.
|
||||||
|
|
||||||
|
Pruefen:
|
||||||
|
|
||||||
|
- Darf Bruttolohn im HR-KPI-Reiter angezeigt werden?
|
||||||
|
- Falls ja: welche Rollen duerfen ihn sehen?
|
||||||
|
- Falls nein: Feld im UI konsequent ausblenden oder gar nicht laden.
|
||||||
|
|
||||||
|
Status:
|
||||||
|
|
||||||
|
- vor produktivem Einsatz mit Datenschutz/HR klaeren
|
||||||
|
|
||||||
|
### 11. Altersgruppen / Geschlecht
|
||||||
|
|
||||||
|
Aktueller Reiter:
|
||||||
|
|
||||||
|
- Alter und Geschlecht werden berechnet/gemappt.
|
||||||
|
- Noch keine spezifischen Diversity-/Altersstruktur-Kacheln.
|
||||||
|
|
||||||
|
Pruefen:
|
||||||
|
|
||||||
|
- Soll Geschlecht nach Schweizer Datenschutz-/HR-Kontext im Cockpit sichtbar sein?
|
||||||
|
- Aggregiert ja/nein?
|
||||||
|
- Mindestgruppengroessen fuer Anzeige definieren, damit keine Einzelpersonen ableitbar sind.
|
||||||
|
|
||||||
|
Status:
|
||||||
|
|
||||||
|
- Daten vorhanden
|
||||||
|
- Anzeige/Datenschutz noch nicht entschieden
|
||||||
|
|
||||||
|
### 12. Personalschluessel / Join-Qualitaet
|
||||||
|
|
||||||
|
Aktueller Reiter:
|
||||||
|
|
||||||
|
- Rexx #757 und SAP werden ueber Personalnummer verbunden.
|
||||||
|
- Rexx #732 wird ueber Name verbunden, weil keine Personalnummer vorhanden ist.
|
||||||
|
|
||||||
|
Risiko:
|
||||||
|
|
||||||
|
- Name-Join ist fehleranfaellig bei gleichen Namen, Namensaenderungen, Sonderzeichen oder Formatabweichungen.
|
||||||
|
|
||||||
|
Pruefen:
|
||||||
|
|
||||||
|
- Gibt es in #732 doch eine stabile ID?
|
||||||
|
- Falls nein: Join-Trefferquote anzeigen.
|
||||||
|
- Nicht gematchte Namen separat ausweisen.
|
||||||
|
|
||||||
|
Status:
|
||||||
|
|
||||||
|
- wichtigster technischer Qualitaetspruefpunkt
|
||||||
|
|
||||||
|
## Empfohlene Mindestkontrollen vor produktiver Nutzung
|
||||||
|
|
||||||
|
1. Kontrollwerte aus Power BI / HR gegen neuen Reiter vergleichen:
|
||||||
|
- `Austritte Total Rexx = 104`
|
||||||
|
- `Austritte Arbeitnehmerkuendigung = 42`
|
||||||
|
- `Austritte Fluktuationsrelevant = 33`
|
||||||
|
2. Headcount aktiv gegen Rexx/HR-Stichtagszahl vergleichen.
|
||||||
|
3. FTE-Summe gegen SAP/HR vergleichen.
|
||||||
|
4. Krankheitstage aus Rexx direkt gegen Export-Summe vergleichen.
|
||||||
|
5. BU/NBU-Tage gegen SAP-Datei summieren.
|
||||||
|
6. Stichprobe von mindestens 10 Mitarbeitenden pruefen:
|
||||||
|
- Personalnummer
|
||||||
|
- Organisation
|
||||||
|
- FTE
|
||||||
|
- GLZ
|
||||||
|
- Ferien Rest
|
||||||
|
- Krankheitstage
|
||||||
|
7. Join-Qualitaet dokumentieren:
|
||||||
|
- Anzahl Rexx-Hauptzeilen
|
||||||
|
- Anzahl SAP-Treffer
|
||||||
|
- Anzahl #732-Name-Treffer
|
||||||
|
- Anzahl nicht gematcht
|
||||||
|
|
||||||
|
## Empfehlung fuer die naechste Umsetzung
|
||||||
|
|
||||||
|
Noch keine Formel aendern, bevor die Kontrollwerte protokolliert sind.
|
||||||
|
|
||||||
|
Sinnvolle naechste technische Erweiterungen:
|
||||||
|
|
||||||
|
- Tab `Datenstatus` um Join-Trefferquoten erweitern.
|
||||||
|
- Tab `Fluktuation` mit Kontrollwerten Power BI/HR anzeigen.
|
||||||
|
- Absenzenquote optional auf vertragliche Sollzeit/FTE umstellen.
|
||||||
|
- Kennzahlen mit `Naeherung` markieren, solange nur ein Stichtagsbestand statt historischer Monats-Snapshots vorhanden ist.
|
||||||
|
|
||||||
Reference in New Issue
Block a user