Add published HR KPI workflow fixes
This commit is contained in:
@@ -1,8 +1,23 @@
|
|||||||
# Build artifacts
|
# Build artifacts
|
||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
|
verify_probe_out*/
|
||||||
|
build_verify/
|
||||||
|
output/
|
||||||
|
|
||||||
# Visual Studio user/IDE files
|
# Visual Studio user/IDE files
|
||||||
.vs/
|
.vs/
|
||||||
*.user
|
*.user
|
||||||
*.suo
|
*.suo
|
||||||
|
|
||||||
|
# Local diagnostics and scratch artifacts
|
||||||
|
.config/
|
||||||
|
.tmp_tools/
|
||||||
|
Tools/FinanceProbe/.tmp_tools/
|
||||||
|
Tools/FinanceProbe/verify_probe_out*/
|
||||||
|
*.out.log
|
||||||
|
*.err.log
|
||||||
|
mainapp*.log
|
||||||
|
financeprobe*.log
|
||||||
|
netsh
|
||||||
|
11.15.0
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
@inject IAdminAccessService AdminAccess
|
@inject IAdminAccessService AdminAccess
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
@inject IUiTextService UiText
|
@inject IUiTextService UiText
|
||||||
|
@inject ILogger<AdminAccessPanel> Logger
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
<MudPaper Class="pa-4 mb-4" Elevation="1" Style="max-width:520px;">
|
<MudPaper Class="pa-4 mb-4" Elevation="1" Style="max-width:520px;">
|
||||||
<MudStack Spacing="3">
|
<MudStack Spacing="3">
|
||||||
@@ -14,12 +16,20 @@
|
|||||||
@T("Admin-Zugang ist noch nicht konfiguriert.", "Admin access is not configured yet.")
|
@T("Admin-Zugang ist noch nicht konfiguriert.", "Admin access is not configured yet.")
|
||||||
</MudAlert>
|
</MudAlert>
|
||||||
}
|
}
|
||||||
<MudTextField @bind-Value="_username" Label="@T("Name", "Name")" Disabled="@(!AdminAccess.IsConfigured)" />
|
<form method="post" action="@AccessUrl">
|
||||||
<MudTextField @bind-Value="_password" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!AdminAccess.IsConfigured)" />
|
<input type="hidden" name="returnUrl" value="@Navigation.Uri" />
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Unlock"
|
<MudStack Spacing="3">
|
||||||
StartIcon="@Icons.Material.Filled.LockOpen" Disabled="@(!AdminAccess.IsConfigured)">
|
<MudTextField T="string" Name="username" Label="@T("Name", "Name")" Disabled="@(!AdminAccess.IsConfigured)" />
|
||||||
@T("Admin entsperren", "Unlock admin")
|
<MudTextField T="string" Name="password" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!AdminAccess.IsConfigured)" />
|
||||||
</MudButton>
|
<button type="submit" class="mud-button-root mud-button mud-button-filled mud-button-filled-primary mud-button-filled-size-medium mud-ripple">
|
||||||
|
@T("Admin entsperren", "Unlock admin")
|
||||||
|
</button>
|
||||||
|
</MudStack>
|
||||||
|
</form>
|
||||||
|
<MudText Typo="Typo.caption">
|
||||||
|
@T("Server-Klicks", "Server clicks"): @_unlockClickCount |
|
||||||
|
@T("Konfiguriert", "Configured"): @(AdminAccess.IsConfigured ? "JA" : "NEIN")
|
||||||
|
</MudText>
|
||||||
<MudDivider />
|
<MudDivider />
|
||||||
<MudExpansionPanels Elevation="0">
|
<MudExpansionPanels Elevation="0">
|
||||||
<MudExpansionPanel Text="@T("Passwort ändern", "Change password")" Icon="@Icons.Material.Filled.Password">
|
<MudExpansionPanel Text="@T("Passwort ändern", "Change password")" Icon="@Icons.Material.Filled.Password">
|
||||||
@@ -45,9 +55,19 @@
|
|||||||
private string? _currentPassword;
|
private string? _currentPassword;
|
||||||
private string? _newPassword;
|
private string? _newPassword;
|
||||||
private string? _newPasswordRepeat;
|
private string? _newPasswordRepeat;
|
||||||
|
private int _unlockClickCount;
|
||||||
|
private string AccessUrl => new Uri(new Uri(Navigation.BaseUri), "access/admin").ToString();
|
||||||
|
|
||||||
private void Unlock()
|
private void Unlock()
|
||||||
{
|
{
|
||||||
|
_unlockClickCount++;
|
||||||
|
Logger.LogInformation(
|
||||||
|
"Admin unlock button handler reached. ClickCount={ClickCount}, IsConfigured={IsConfigured}, UsernameLength={UsernameLength}, PasswordLength={PasswordLength}",
|
||||||
|
_unlockClickCount,
|
||||||
|
AdminAccess.IsConfigured,
|
||||||
|
_username?.Length ?? 0,
|
||||||
|
_password?.Length ?? 0);
|
||||||
|
|
||||||
if (!AdminAccess.TryUnlock(_username ?? string.Empty, _password ?? string.Empty))
|
if (!AdminAccess.TryUnlock(_username ?? string.Empty, _password ?? string.Empty))
|
||||||
{
|
{
|
||||||
Snackbar.Add(T("Admin-Anmeldung fehlgeschlagen.", "Admin sign-in failed."), Severity.Error);
|
Snackbar.Add(T("Admin-Anmeldung fehlgeschlagen.", "Admin sign-in failed."), Severity.Error);
|
||||||
|
|||||||
@@ -12,7 +12,14 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<Routes @rendermode="@Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer" />
|
<Routes @rendermode="@Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer" />
|
||||||
<script src="_framework/blazor.web.js"></script>
|
<script src="@($"{BaseHref}_framework/blazor.web.js")" autostart="false"></script>
|
||||||
|
<script>
|
||||||
|
Blazor.start({
|
||||||
|
circuit: {
|
||||||
|
configureSignalR: builder => builder.withUrl('@($"{BaseHref}_blazor")')
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||||
<script src="js/download.js"></script>
|
<script src="js/download.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
@using TrafagSalesExporter.Services
|
@using TrafagSalesExporter.Services
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
@inject IFinanceCockpitAccessService FinanceAccess
|
@inject IFinanceCockpitAccessService FinanceAccess
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject IUiTextService UiText
|
@inject IUiTextService UiText
|
||||||
|
@inject ILogger<FinanceCockpitUnlockPanel> Logger
|
||||||
|
|
||||||
<MudText Typo="Typo.h4" Class="mb-4">@T("Finance Cockpit", "Finance Cockpit")</MudText>
|
<MudText Typo="Typo.h4" Class="mb-4">@T("Finance Cockpit", "Finance Cockpit")</MudText>
|
||||||
|
|
||||||
@@ -17,12 +19,20 @@
|
|||||||
@T("Finance-Cockpit-Zugang ist noch nicht konfiguriert. Bitte Username und PasswordHash in FinanceCockpitAccess konfigurieren.", "Finance Cockpit access is not configured yet. Please configure Username and PasswordHash in FinanceCockpitAccess.")
|
@T("Finance-Cockpit-Zugang ist noch nicht konfiguriert. Bitte Username und PasswordHash in FinanceCockpitAccess konfigurieren.", "Finance Cockpit access is not configured yet. Please configure Username and PasswordHash in FinanceCockpitAccess.")
|
||||||
</MudAlert>
|
</MudAlert>
|
||||||
}
|
}
|
||||||
<MudTextField @bind-Value="_username" Label="@T("Name", "Name")" Disabled="@(!FinanceAccess.IsConfigured)" />
|
<form method="post" action="@AccessUrl">
|
||||||
<MudTextField @bind-Value="_password" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!FinanceAccess.IsConfigured)" />
|
<input type="hidden" name="returnUrl" value="@Navigation.Uri" />
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="UnlockAsync"
|
<MudStack Spacing="3">
|
||||||
StartIcon="@Icons.Material.Filled.LockOpen" Disabled="@(!FinanceAccess.IsConfigured)">
|
<MudTextField T="string" Name="username" Label="@T("Name", "Name")" Disabled="@(!FinanceAccess.IsConfigured)" />
|
||||||
@T("Finance Cockpit entsperren", "Unlock Finance Cockpit")
|
<MudTextField T="string" Name="password" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!FinanceAccess.IsConfigured)" />
|
||||||
</MudButton>
|
<button type="submit" class="mud-button-root mud-button mud-button-filled mud-button-filled-primary mud-button-filled-size-medium mud-ripple">
|
||||||
|
@T("Finance Cockpit entsperren", "Unlock Finance Cockpit")
|
||||||
|
</button>
|
||||||
|
</MudStack>
|
||||||
|
</form>
|
||||||
|
<MudText Typo="Typo.caption">
|
||||||
|
@T("Server-Klicks", "Server clicks"): @_unlockClickCount |
|
||||||
|
@T("Konfiguriert", "Configured"): @(FinanceAccess.IsConfigured ? "JA" : "NEIN")
|
||||||
|
</MudText>
|
||||||
<MudDivider />
|
<MudDivider />
|
||||||
<MudExpansionPanels Elevation="0">
|
<MudExpansionPanels Elevation="0">
|
||||||
<MudExpansionPanel Text="@T("Passwort ändern", "Change password")" Icon="@Icons.Material.Filled.Password">
|
<MudExpansionPanel Text="@T("Passwort ändern", "Change password")" Icon="@Icons.Material.Filled.Password">
|
||||||
@@ -48,9 +58,19 @@
|
|||||||
private string? _currentPassword;
|
private string? _currentPassword;
|
||||||
private string? _newPassword;
|
private string? _newPassword;
|
||||||
private string? _newPasswordRepeat;
|
private string? _newPasswordRepeat;
|
||||||
|
private int _unlockClickCount;
|
||||||
|
private string AccessUrl => new Uri(new Uri(Navigation.BaseUri), "access/finance").ToString();
|
||||||
|
|
||||||
private Task UnlockAsync()
|
private Task UnlockAsync()
|
||||||
{
|
{
|
||||||
|
_unlockClickCount++;
|
||||||
|
Logger.LogInformation(
|
||||||
|
"Finance unlock button handler reached. ClickCount={ClickCount}, IsConfigured={IsConfigured}, UsernameLength={UsernameLength}, PasswordLength={PasswordLength}",
|
||||||
|
_unlockClickCount,
|
||||||
|
FinanceAccess.IsConfigured,
|
||||||
|
_username?.Length ?? 0,
|
||||||
|
_password?.Length ?? 0);
|
||||||
|
|
||||||
if (!FinanceAccess.TryUnlock(_username ?? string.Empty, _password ?? string.Empty))
|
if (!FinanceAccess.TryUnlock(_username ?? string.Empty, _password ?? string.Empty))
|
||||||
{
|
{
|
||||||
Snackbar.Add(T("Finance-Cockpit-Anmeldung fehlgeschlagen.", "Finance Cockpit sign-in failed."), Severity.Error);
|
Snackbar.Add(T("Finance-Cockpit-Anmeldung fehlgeschlagen.", "Finance Cockpit sign-in failed."), Severity.Error);
|
||||||
|
|||||||
@@ -2,9 +2,13 @@
|
|||||||
@using TrafagSalesExporter.Models
|
@using TrafagSalesExporter.Models
|
||||||
@using TrafagSalesExporter.Services
|
@using TrafagSalesExporter.Services
|
||||||
@inject IUiTextService UiText
|
@inject IUiTextService UiText
|
||||||
|
@inject IJSRuntime JsRuntime
|
||||||
|
|
||||||
<MudTabs Elevation="1" Rounded="false" PanelClass="pt-4">
|
<MudTabs Elevation="1" Rounded="false" PanelClass="pt-4">
|
||||||
<MudTabPanel Text="@T("Ueberblick", "Overview")" Icon="@Icons.Material.Filled.Dashboard">
|
<MudTabPanel Text="@T("Ueberblick", "Overview")" Icon="@Icons.Material.Filled.Dashboard">
|
||||||
|
@PrintToolbar("hr-kpi-print-overview", T("Ueberblick als PDF", "Overview as PDF"))
|
||||||
|
<div id="hr-kpi-print-overview" class="hr-print-section">
|
||||||
|
@PrintHeader(T("Ueberblick", "Overview"))
|
||||||
@MetricGrid(Result.Metrics)
|
@MetricGrid(Result.Metrics)
|
||||||
|
|
||||||
<MudGrid Class="mt-4">
|
<MudGrid Class="mt-4">
|
||||||
@@ -24,9 +28,13 @@
|
|||||||
@CriticalBalancesTable(Result.CriticalTimeBalances)
|
@CriticalBalancesTable(Result.CriticalTimeBalances)
|
||||||
</MudItem>
|
</MudItem>
|
||||||
</MudGrid>
|
</MudGrid>
|
||||||
|
</div>
|
||||||
</MudTabPanel>
|
</MudTabPanel>
|
||||||
|
|
||||||
<MudTabPanel Text="@T("Fluktuation", "Turnover")" Icon="@Icons.Material.Filled.TrendingDown">
|
<MudTabPanel Text="@T("Fluktuation", "Turnover")" Icon="@Icons.Material.Filled.TrendingDown">
|
||||||
|
@PrintToolbar("hr-kpi-print-turnover", T("Fluktuation als PDF", "Turnover as PDF"))
|
||||||
|
<div id="hr-kpi-print-turnover" class="hr-print-section">
|
||||||
|
@PrintHeader(T("Fluktuation", "Turnover"))
|
||||||
@MetricGrid(Result.TurnoverMetrics)
|
@MetricGrid(Result.TurnoverMetrics)
|
||||||
|
|
||||||
<MudGrid Class="mt-4">
|
<MudGrid Class="mt-4">
|
||||||
@@ -67,14 +75,22 @@
|
|||||||
@MonthlyBars(Result.TurnoverVisuals)
|
@MonthlyBars(Result.TurnoverVisuals)
|
||||||
</MudItem>
|
</MudItem>
|
||||||
</MudGrid>
|
</MudGrid>
|
||||||
|
</div>
|
||||||
</MudTabPanel>
|
</MudTabPanel>
|
||||||
|
|
||||||
<MudTabPanel Text="@T("Ampel", "Status")" Icon="@Icons.Material.Filled.Traffic">
|
<MudTabPanel Text="@T("Ampel", "Status")" Icon="@Icons.Material.Filled.Traffic">
|
||||||
|
@PrintToolbar("hr-kpi-print-status", T("Ampel als PDF", "Status as PDF"))
|
||||||
|
<div id="hr-kpi-print-status" class="hr-print-section">
|
||||||
|
@PrintHeader(T("Ampel", "Status"))
|
||||||
@TrafficLightPanel(Result.TrafficLights)
|
@TrafficLightPanel(Result.TrafficLights)
|
||||||
@MetricGrid(Result.PeriodComparisonMetrics)
|
@MetricGrid(Result.PeriodComparisonMetrics)
|
||||||
|
</div>
|
||||||
</MudTabPanel>
|
</MudTabPanel>
|
||||||
|
|
||||||
<MudTabPanel Text="@T("Absenzen", "Absences")" Icon="@Icons.Material.Filled.Sick">
|
<MudTabPanel Text="@T("Absenzen", "Absences")" Icon="@Icons.Material.Filled.Sick">
|
||||||
|
@PrintToolbar("hr-kpi-print-absences", T("Absenzen als PDF", "Absences as PDF"))
|
||||||
|
<div id="hr-kpi-print-absences" class="hr-print-section">
|
||||||
|
@PrintHeader(T("Absenzen", "Absences"))
|
||||||
@MetricGrid(Result.AbsenceMetrics)
|
@MetricGrid(Result.AbsenceMetrics)
|
||||||
<MudGrid Class="mt-4">
|
<MudGrid Class="mt-4">
|
||||||
<MudItem xs="12" md="6">
|
<MudItem xs="12" md="6">
|
||||||
@@ -110,9 +126,13 @@
|
|||||||
</PagerContent>
|
</PagerContent>
|
||||||
</MudTable>
|
</MudTable>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
</div>
|
||||||
</MudTabPanel>
|
</MudTabPanel>
|
||||||
|
|
||||||
<MudTabPanel Text="@T("Zeit / Ferien", "Time / Vacation")" Icon="@Icons.Material.Filled.EventAvailable">
|
<MudTabPanel Text="@T("Zeit / Ferien", "Time / Vacation")" Icon="@Icons.Material.Filled.EventAvailable">
|
||||||
|
@PrintToolbar("hr-kpi-print-time-vacation", T("Zeit/Ferien als PDF", "Time/vacation as PDF"))
|
||||||
|
<div id="hr-kpi-print-time-vacation" class="hr-print-section">
|
||||||
|
@PrintHeader(T("Zeit / Ferien", "Time / Vacation"))
|
||||||
@MetricGrid(Result.TimeVacationMetrics)
|
@MetricGrid(Result.TimeVacationMetrics)
|
||||||
|
|
||||||
<MudGrid Class="mt-4">
|
<MudGrid Class="mt-4">
|
||||||
@@ -145,19 +165,28 @@
|
|||||||
</MudPaper>
|
</MudPaper>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
</MudGrid>
|
</MudGrid>
|
||||||
|
</div>
|
||||||
</MudTabPanel>
|
</MudTabPanel>
|
||||||
|
|
||||||
<MudTabPanel Text="@T("Mitarbeitende", "Employees")" Icon="@Icons.Material.Filled.Groups">
|
<MudTabPanel Text="@T("Mitarbeitende", "Employees")" Icon="@Icons.Material.Filled.Groups">
|
||||||
|
@PrintToolbar("hr-kpi-print-employees", T("Mitarbeitende als PDF", "Employees as PDF"))
|
||||||
|
<div id="hr-kpi-print-employees" class="hr-print-section">
|
||||||
|
@PrintHeader(T("Mitarbeitende", "Employees"))
|
||||||
@EmployeesTable(Result.Employees)
|
@EmployeesTable(Result.Employees)
|
||||||
|
</div>
|
||||||
</MudTabPanel>
|
</MudTabPanel>
|
||||||
|
|
||||||
<MudTabPanel Text="@T("Datenstatus", "Data status")" Icon="@Icons.Material.Filled.FactCheck">
|
<MudTabPanel Text="@T("Datenstatus", "Data status")" Icon="@Icons.Material.Filled.FactCheck">
|
||||||
|
@PrintToolbar("hr-kpi-print-data-status", T("Datenstatus als PDF", "Data status as PDF"))
|
||||||
|
<div id="hr-kpi-print-data-status" class="hr-print-section">
|
||||||
|
@PrintHeader(T("Datenstatus", "Data status"))
|
||||||
@FileStatusTable(Result.FileStatuses)
|
@FileStatusTable(Result.FileStatuses)
|
||||||
<MudGrid Class="mt-4">
|
<MudGrid Class="mt-4">
|
||||||
<MudItem xs="12">
|
<MudItem xs="12">
|
||||||
@DataQualityTable(Result.DataQualityIssues)
|
@DataQualityTable(Result.DataQualityIssues)
|
||||||
</MudItem>
|
</MudItem>
|
||||||
</MudGrid>
|
</MudGrid>
|
||||||
|
</div>
|
||||||
</MudTabPanel>
|
</MudTabPanel>
|
||||||
|
|
||||||
<MudTabPanel Text="@T("Anleitung", "Guide")" Icon="@Icons.Material.Filled.HelpOutline">
|
<MudTabPanel Text="@T("Anleitung", "Guide")" Icon="@Icons.Material.Filled.HelpOutline">
|
||||||
@@ -170,6 +199,42 @@
|
|||||||
|
|
||||||
private string T(string german, string english) => UiText.Text(german, english);
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
|
||||||
|
private RenderFragment PrintToolbar(string targetId, string label) => @<MudStack Row Justify="Justify.FlexEnd" Class="mb-3 hr-print-toolbar">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Size="Size.Small"
|
||||||
|
StartIcon="@Icons.Material.Filled.PictureAsPdf"
|
||||||
|
OnClick="@(() => PrintSectionAsync(targetId))">
|
||||||
|
@label
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>;
|
||||||
|
|
||||||
|
private RenderFragment PrintHeader(string title) => @<div class="hr-print-header">
|
||||||
|
<h1>@title</h1>
|
||||||
|
<p>@Result.Options.DataFolder</p>
|
||||||
|
<p>@BuildFilterSummary()</p>
|
||||||
|
</div>;
|
||||||
|
|
||||||
|
private async Task PrintSectionAsync(string targetId)
|
||||||
|
{
|
||||||
|
await JsRuntime.InvokeVoidAsync("trafagDownload.printElement", targetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildFilterSummary()
|
||||||
|
{
|
||||||
|
var parts = new List<string>();
|
||||||
|
if (Result.Options.FromDate.HasValue || Result.Options.ToDate.HasValue)
|
||||||
|
parts.Add($"{T("Zeitraum", "Period")}: {FormatDate(Result.Options.FromDate)} - {FormatDate(Result.Options.ToDate)}");
|
||||||
|
if (Result.Options.Year.HasValue)
|
||||||
|
parts.Add($"{T("Austrittsjahr", "Leaver year")}: {Result.Options.Year.Value}");
|
||||||
|
parts.Add($"{T("Organisation", "Organisation")}: {BlankAsAll(Result.Options.Organisationseinheit)}");
|
||||||
|
parts.Add($"{T("Mitarbeitertyp", "Employee type")}: {BlankAsAll(Result.Options.Mitarbeitertyp)}");
|
||||||
|
if (!string.IsNullOrWhiteSpace(Result.Options.KostenstelleText))
|
||||||
|
parts.Add($"{T("Kostenstelle", "Cost center")}: {Result.Options.KostenstelleText}");
|
||||||
|
return string.Join(" | ", parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BlankAsAll(string? value)
|
||||||
|
=> string.IsNullOrWhiteSpace(value) ? T("Alle", "All") : value;
|
||||||
|
|
||||||
private static Color MetricColor(string severity)
|
private static Color MetricColor(string severity)
|
||||||
=> severity == "Warning" ? Color.Warning : Color.Default;
|
=> severity == "Warning" ? Color.Warning : Color.Default;
|
||||||
|
|
||||||
@@ -629,6 +694,14 @@
|
|||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hr-print-header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-print-toolbar {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.hr-guide-steps {
|
.hr-guide-steps {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(150px, 1fr));
|
grid-template-columns: repeat(4, minmax(150px, 1fr));
|
||||||
|
|||||||
@@ -7,42 +7,42 @@
|
|||||||
|
|
||||||
<MudNavMenu>
|
<MudNavMenu>
|
||||||
<MudNavGroup Title="@T("Finance Cockpit", "Finance Cockpit")" Icon="@Icons.Material.Filled.Analytics" Expanded="true">
|
<MudNavGroup Title="@T("Finance Cockpit", "Finance Cockpit")" Icon="@Icons.Material.Filled.Analytics" Expanded="true">
|
||||||
<MudNavLink Href="/export-dashboard" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Dashboard">
|
<MudNavLink Href="export-dashboard" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Dashboard">
|
||||||
@T("Export Dashboard", "Export dashboard")
|
@T("Export Dashboard", "Export dashboard")
|
||||||
</MudNavLink>
|
</MudNavLink>
|
||||||
<MudNavLink Href="/management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.QueryStats">
|
<MudNavLink Href="management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.QueryStats">
|
||||||
@T("Management Analyse", "Management analysis")
|
@T("Management Analyse", "Management analysis")
|
||||||
</MudNavLink>
|
</MudNavLink>
|
||||||
@if (ShowFinanceComparison)
|
@if (ShowFinanceComparison)
|
||||||
{
|
{
|
||||||
<MudNavLink Href="/finance-cockpit/vergleich" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.CompareArrows">
|
<MudNavLink Href="finance-cockpit/vergleich" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.CompareArrows">
|
||||||
@T("Soll/Ist Vergleich", "Actual/reference comparison")
|
@T("Soll/Ist Vergleich", "Actual/reference comparison")
|
||||||
</MudNavLink>
|
</MudNavLink>
|
||||||
}
|
}
|
||||||
<MudNavLink Href="/finance-cockpit/schulung" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.School">
|
<MudNavLink Href="finance-cockpit/schulung" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.School">
|
||||||
@T("Finance Schulung", "Finance training")
|
@T("Finance Schulung", "Finance training")
|
||||||
</MudNavLink>
|
</MudNavLink>
|
||||||
<MudNavLink Href="/manual-imports" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">
|
<MudNavLink Href="manual-imports" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">
|
||||||
@T("Manuelle Importe", "Manual imports")
|
@T("Manuelle Importe", "Manual imports")
|
||||||
</MudNavLink>
|
</MudNavLink>
|
||||||
<MudNavGroup Title="@T("Admin", "Admin")" Icon="@Icons.Material.Filled.AdminPanelSettings">
|
<MudNavGroup Title="@T("Admin", "Admin")" Icon="@Icons.Material.Filled.AdminPanelSettings">
|
||||||
<AuthorizeView Policy="@SecurityPolicies.AdminOnly">
|
<AuthorizeView Policy="@SecurityPolicies.AdminOnly">
|
||||||
<Authorized>
|
<Authorized>
|
||||||
<MudNavLink Href="/standorte" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.LocationOn">
|
<MudNavLink Href="standorte" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.LocationOn">
|
||||||
@T("Standorte", "Sites")
|
@T("Standorte", "Sites")
|
||||||
</MudNavLink>
|
</MudNavLink>
|
||||||
<MudNavLink Href="/transformations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Transform">
|
<MudNavLink Href="transformations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Transform">
|
||||||
@T("Transformationen", "Transformations")
|
@T("Transformationen", "Transformations")
|
||||||
</MudNavLink>
|
</MudNavLink>
|
||||||
<MudNavLink Href="/finance-rules" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Rule">
|
<MudNavLink Href="finance-rules" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Rule">
|
||||||
@T("Finance Regeln", "Finance rules")
|
@T("Finance Regeln", "Finance rules")
|
||||||
</MudNavLink>
|
</MudNavLink>
|
||||||
<MudNavLink Href="/settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Settings">
|
<MudNavLink Href="settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Settings">
|
||||||
@T("Settings", "Settings")
|
@T("Settings", "Settings")
|
||||||
</MudNavLink>
|
</MudNavLink>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
<MudNavLink Href="/logs" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.List">
|
<MudNavLink Href="logs" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.List">
|
||||||
@T("Logs", "Logs")
|
@T("Logs", "Logs")
|
||||||
</MudNavLink>
|
</MudNavLink>
|
||||||
</MudNavGroup>
|
</MudNavGroup>
|
||||||
@@ -55,14 +55,14 @@
|
|||||||
}
|
}
|
||||||
</MudNavGroup>
|
</MudNavGroup>
|
||||||
<MudNavGroup Title="@T("HR KPI (Login)", "HR KPI (login)")" Icon="@Icons.Material.Filled.Groups">
|
<MudNavGroup Title="@T("HR KPI (Login)", "HR KPI (login)")" Icon="@Icons.Material.Filled.Groups">
|
||||||
<MudNavLink Href="/hr-kpi" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">
|
<MudNavLink Href="hr-kpi" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">
|
||||||
@T("HR Dashboard", "HR dashboard")
|
@T("HR Dashboard", "HR dashboard")
|
||||||
</MudNavLink>
|
</MudNavLink>
|
||||||
<MudNavLink Href="/hr-kpi/schulung" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.School">
|
<MudNavLink Href="hr-kpi/schulung" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.School">
|
||||||
@T("HR KPI Schulung", "HR KPI training")
|
@T("HR KPI Schulung", "HR KPI training")
|
||||||
</MudNavLink>
|
</MudNavLink>
|
||||||
</MudNavGroup>
|
</MudNavGroup>
|
||||||
<MudNavLink Href="/admin/sessions" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.PeopleAlt">
|
<MudNavLink Href="admin/sessions" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.PeopleAlt">
|
||||||
@T("Admin Bereich", "Admin area")
|
@T("Admin Bereich", "Admin area")
|
||||||
</MudNavLink>
|
</MudNavLink>
|
||||||
</MudNavMenu>
|
</MudNavMenu>
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
private void LockFinanceCockpit()
|
private void LockFinanceCockpit()
|
||||||
{
|
{
|
||||||
FinanceAccess.Lock();
|
FinanceAccess.Lock();
|
||||||
Navigation.NavigateTo("/");
|
Navigation.NavigateTo(string.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandleLanguageChanged()
|
private void HandleLanguageChanged()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@page "/admin/sessions"
|
@page "/admin/sessions"
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
|
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
|
||||||
@using TrafagSalesExporter.Services
|
@using TrafagSalesExporter.Services
|
||||||
@inject IAccessSessionTracker SessionTracker
|
@inject IAccessSessionTracker SessionTracker
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
@using TrafagSalesExporter.Services
|
@using TrafagSalesExporter.Services
|
||||||
@inject IUiTextService UiText
|
@inject IUiTextService UiText
|
||||||
@inject ILandingPageSettingsService LandingSettings
|
@inject ILandingPageSettingsService LandingSettings
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@page "/export-dashboard"
|
@page "/export-dashboard"
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
@using System.Diagnostics
|
@using System.Diagnostics
|
||||||
@using TrafagSalesExporter.Services
|
@using TrafagSalesExporter.Services
|
||||||
@inject IDashboardPageService DashboardPageActions
|
@inject IDashboardPageService DashboardPageActions
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@page "/finance-cockpit/vergleich"
|
@page "/finance-cockpit/vergleich"
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
@using TrafagSalesExporter.Models
|
@using TrafagSalesExporter.Models
|
||||||
@using TrafagSalesExporter.Services
|
@using TrafagSalesExporter.Services
|
||||||
@inject IFinanceReconciliationService FinanceReconciliationService
|
@inject IFinanceReconciliationService FinanceReconciliationService
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@page "/finance-rules"
|
@page "/finance-rules"
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
|
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
|
||||||
@using System.Reflection
|
@using System.Reflection
|
||||||
@using TrafagSalesExporter.Models
|
@using TrafagSalesExporter.Models
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@page "/finance-cockpit/schulung"
|
@page "/finance-cockpit/schulung"
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
@inject TrafagSalesExporter.Services.IUiTextService UiText
|
@inject TrafagSalesExporter.Services.IUiTextService UiText
|
||||||
|
|
||||||
<PageTitle>@T("Finance Schulung", "Finance training")</PageTitle>
|
<PageTitle>@T("Finance Schulung", "Finance training")</PageTitle>
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
@page "/hr-kpi"
|
@page "/hr-kpi"
|
||||||
@using Microsoft.Extensions.Options
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
|
@using System.Globalization
|
||||||
|
@using System.Text.Json
|
||||||
@using TrafagSalesExporter.Components.HrKpi
|
@using TrafagSalesExporter.Components.HrKpi
|
||||||
@using TrafagSalesExporter.Services
|
@using TrafagSalesExporter.Services
|
||||||
@inject IHrKpiService HrKpiService
|
@inject IHrKpiService HrKpiService
|
||||||
@inject IOptions<HrKpiDataSourceOptions> DataSourceOptions
|
|
||||||
@inject IHrKpiAccessService HrKpiAccess
|
@inject IHrKpiAccessService HrKpiAccess
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
@inject IUiTextService UiText
|
@inject IUiTextService UiText
|
||||||
@inject IJSRuntime JsRuntime
|
@inject IJSRuntime JsRuntime
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject IWebHostEnvironment Environment
|
||||||
|
|
||||||
<PageTitle>@T("HR KPI", "HR KPI")</PageTitle>
|
<PageTitle>@T("HR KPI", "HR KPI")</PageTitle>
|
||||||
|
|
||||||
@@ -26,12 +29,20 @@
|
|||||||
@T("HR-KPI-Zugang ist noch nicht konfiguriert. Bitte Username und PasswordHash in HrKpiAccess konfigurieren.", "HR KPI access is not configured yet. Please configure Username and PasswordHash in HrKpiAccess.")
|
@T("HR-KPI-Zugang ist noch nicht konfiguriert. Bitte Username und PasswordHash in HrKpiAccess konfigurieren.", "HR KPI access is not configured yet. Please configure Username and PasswordHash in HrKpiAccess.")
|
||||||
</MudAlert>
|
</MudAlert>
|
||||||
}
|
}
|
||||||
<MudTextField @bind-Value="_hrUsername" Label="@T("Name", "Name")" Disabled="@(!HrKpiAccess.IsConfigured)" />
|
<form method="post" action="@AccessUrl">
|
||||||
<MudTextField @bind-Value="_hrPassword" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!HrKpiAccess.IsConfigured)" />
|
<input type="hidden" name="returnUrl" value="@Navigation.Uri" />
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="UnlockHrKpiAsync"
|
<MudStack Spacing="3">
|
||||||
StartIcon="@Icons.Material.Filled.LockOpen" Disabled="@(!HrKpiAccess.IsConfigured)">
|
<MudTextField T="string" Name="username" Label="@T("Name", "Name")" Disabled="@(!HrKpiAccess.IsConfigured)" />
|
||||||
@T("HR KPI entsperren", "Unlock HR KPI")
|
<MudTextField T="string" Name="password" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!HrKpiAccess.IsConfigured)" />
|
||||||
</MudButton>
|
<button type="submit" class="mud-button-root mud-button mud-button-filled mud-button-filled-primary mud-button-filled-size-medium mud-ripple">
|
||||||
|
@T("HR KPI entsperren", "Unlock HR KPI")
|
||||||
|
</button>
|
||||||
|
</MudStack>
|
||||||
|
</form>
|
||||||
|
<MudText Typo="Typo.caption">
|
||||||
|
@T("Server-Klicks", "Server clicks"): @_unlockClickCount |
|
||||||
|
@T("Konfiguriert", "Configured"): @(HrKpiAccess.IsConfigured ? "JA" : "NEIN")
|
||||||
|
</MudText>
|
||||||
<MudDivider />
|
<MudDivider />
|
||||||
<MudExpansionPanels Elevation="0">
|
<MudExpansionPanels Elevation="0">
|
||||||
<MudExpansionPanel Text="@T("Passwort ändern", "Change password")" Icon="@Icons.Material.Filled.Password">
|
<MudExpansionPanel Text="@T("Passwort ändern", "Change password")" Icon="@Icons.Material.Filled.Password">
|
||||||
@@ -57,7 +68,24 @@ else
|
|||||||
<MudItem xs="12" md="5">
|
<MudItem xs="12" md="5">
|
||||||
<MudTextField @bind-Value="_dataFolder"
|
<MudTextField @bind-Value="_dataFolder"
|
||||||
Label="@T("Datenordner fuer Rexx/SAP-Dateien", "Data folder for Rexx/SAP files")"
|
Label="@T("Datenordner fuer Rexx/SAP-Dateien", "Data folder for Rexx/SAP files")"
|
||||||
HelperText="@T("Standard ist C:\\temp. Der Ordner kann hier fuer den aktuellen Lauf angepasst oder dauerhaft in appsettings.json unter HrKpi:DataFolder geaendert werden.", "Default is C:\\temp. The folder can be changed here for the current run or permanently in appsettings.json under HrKpi:DataFolder.")" />
|
HelperText="@T("Serverordner fuer hochgeladene HR-KPI-Dateien. Auf der publizierten Webseite ist das ein Ordner auf dem Webserver, nicht C:\\temp auf dem lokalen PC.", "Server folder for uploaded HR KPI files. On the published site this is a folder on the web server, not C:\\temp on the local PC.")" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="5">
|
||||||
|
<MudStack Spacing="1">
|
||||||
|
<MudText Typo="Typo.caption">
|
||||||
|
@T("Erwartete Dateien", "Expected files"): @string.Join(", ", ExpectedUploadFileNames)
|
||||||
|
</MudText>
|
||||||
|
<InputFile OnChange="UploadHrKpiFilesAsync" multiple accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" disabled="@(_loading || _uploading)" />
|
||||||
|
<MudText Typo="Typo.caption">
|
||||||
|
@T("Uploadziel", "Upload target"): @_serverUploadFolder
|
||||||
|
</MudText>
|
||||||
|
</MudStack>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="UseServerUploadFolderAsync"
|
||||||
|
StartIcon="@Icons.Material.Filled.Folder" Disabled="@(_loading || _uploading)" FullWidth>
|
||||||
|
@T("Serverordner nutzen", "Use server folder")
|
||||||
|
</MudButton>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="6" md="2">
|
<MudItem xs="6" md="2">
|
||||||
<MudSelect T="int?" @bind-Value="_year" Label="@T("Austrittsjahr", "Leaver year")" Dense Clearable>
|
<MudSelect T="int?" @bind-Value="_year" Label="@T("Austrittsjahr", "Leaver year")" Dense Clearable>
|
||||||
@@ -86,10 +114,10 @@ else
|
|||||||
Label="@T("Managementsicht", "Management view")" />
|
Label="@T("Managementsicht", "Management view")" />
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" md="3">
|
<MudItem xs="12" md="3">
|
||||||
<MudDatePicker @bind-Date="_fromDate" Label="@T("Von Austritt", "Exit from")" Clearable DateFormat="dd.MM.yyyy" />
|
<MudDatePicker @bind-Date="_fromDate" Label="@T("Von Datum", "From date")" Clearable DateFormat="dd.MM.yyyy" Culture="_dateCulture" />
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" md="3">
|
<MudItem xs="12" md="3">
|
||||||
<MudDatePicker @bind-Date="_toDate" Label="@T("Bis Austritt", "Exit to")" Clearable DateFormat="dd.MM.yyyy" />
|
<MudDatePicker @bind-Date="_toDate" Label="@T("Bis Datum", "To date")" Clearable DateFormat="dd.MM.yyyy" Culture="_dateCulture" />
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" md="2">
|
<MudItem xs="12" md="2">
|
||||||
<MudSelect T="int?" @bind-Value="_entryYear" Label="@T("Eintrittsjahr", "Entry year")" Dense Clearable>
|
<MudSelect T="int?" @bind-Value="_entryYear" Label="@T("Eintrittsjahr", "Entry year")" Dense Clearable>
|
||||||
@@ -154,6 +182,50 @@ else
|
|||||||
@T("Drucken/PDF", "Print/PDF")
|
@T("Drucken/PDF", "Print/PDF")
|
||||||
</MudButton>
|
</MudButton>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudDivider Class="my-2" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="3">
|
||||||
|
<MudSelect T="string" @bind-Value="_selectedVariantName" Label="@T("Variante", "Variant")" Dense Clearable>
|
||||||
|
@foreach (var variant in _variantNames)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@variant">@variant</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="3">
|
||||||
|
<MudTextField @bind-Value="_variantName" Label="@T("Variantenname", "Variant name")" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="SaveVariantAsync"
|
||||||
|
StartIcon="@Icons.Material.Filled.Save" Disabled="_loading" FullWidth>
|
||||||
|
@T("Variante speichern", "Save variant")
|
||||||
|
</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="RenameVariantAsync"
|
||||||
|
StartIcon="@Icons.Material.Filled.Edit" Disabled="@(_loading || string.IsNullOrWhiteSpace(_selectedVariantName) || string.IsNullOrWhiteSpace(_variantName))" FullWidth>
|
||||||
|
@T("Umbenennen", "Rename")
|
||||||
|
</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="LoadVariantAsync"
|
||||||
|
StartIcon="@Icons.Material.Filled.FileOpen" Disabled="@(_loading || string.IsNullOrWhiteSpace(_selectedVariantName))" FullWidth>
|
||||||
|
@T("Variante laden", "Load variant")
|
||||||
|
</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="DeleteVariantAsync"
|
||||||
|
StartIcon="@Icons.Material.Filled.Delete" Disabled="@(_loading || string.IsNullOrWhiteSpace(_selectedVariantName))" FullWidth>
|
||||||
|
@T("Löschen", "Delete")
|
||||||
|
</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" OnClick="UpdateSelectedVariantAsync"
|
||||||
|
StartIcon="@Icons.Material.Filled.Update" Disabled="@(_loading || string.IsNullOrWhiteSpace(_selectedVariantName))" FullWidth>
|
||||||
|
@T("Bestehende anpassen", "Update existing")
|
||||||
|
</MudButton>
|
||||||
|
</MudItem>
|
||||||
</MudGrid>
|
</MudGrid>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
}
|
}
|
||||||
@@ -194,8 +266,26 @@ else
|
|||||||
private string? _currentPassword;
|
private string? _currentPassword;
|
||||||
private string? _newPassword;
|
private string? _newPassword;
|
||||||
private string? _newPasswordRepeat;
|
private string? _newPasswordRepeat;
|
||||||
|
private int _unlockClickCount;
|
||||||
|
private string AccessUrl => new Uri(new Uri(Navigation.BaseUri), "access/hr").ToString();
|
||||||
private bool _loading;
|
private bool _loading;
|
||||||
|
private bool _uploading;
|
||||||
|
private string _serverUploadFolder = string.Empty;
|
||||||
private HrKpiResult? _result;
|
private HrKpiResult? _result;
|
||||||
|
private string _selectionStorePath = string.Empty;
|
||||||
|
private HrKpiSelectionStore _selectionStore = new();
|
||||||
|
private string? _variantName;
|
||||||
|
private string? _selectedVariantName;
|
||||||
|
private List<string> _variantNames = [];
|
||||||
|
private static readonly SemaphoreSlim SelectionStoreLock = new(1, 1);
|
||||||
|
private static readonly string[] ExpectedUploadFileNames =
|
||||||
|
[
|
||||||
|
"Saldiperstichdatum.xlsx",
|
||||||
|
"Exportkommengehen.xlsx",
|
||||||
|
"HR_KPI_Export.xlsx",
|
||||||
|
"Abwesenheitinstunden.xlsx",
|
||||||
|
"Personalausgeschieden.xlsx"
|
||||||
|
];
|
||||||
private readonly List<(string Key, string Label)> _fluktuationOptions =
|
private readonly List<(string Key, string Label)> _fluktuationOptions =
|
||||||
[
|
[
|
||||||
("Alle", "Alle"),
|
("Alle", "Alle"),
|
||||||
@@ -205,10 +295,20 @@ else
|
|||||||
];
|
];
|
||||||
private readonly List<string> _ampelOptions = ["Gruen", "Gelb", "Rot"];
|
private readonly List<string> _ampelOptions = ["Gruen", "Gelb", "Rot"];
|
||||||
private readonly List<string> _restferienOptions = ["Gruen", "Rot"];
|
private readonly List<string> _restferienOptions = ["Gruen", "Rot"];
|
||||||
|
private readonly CultureInfo _dateCulture = CultureInfo.GetCultureInfo("de-CH");
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
_dataFolder = DataSourceOptions.Value.Normalize().DataFolder;
|
_serverUploadFolder = Path.Combine(Environment.ContentRootPath, "hrdata");
|
||||||
|
Directory.CreateDirectory(_serverUploadFolder);
|
||||||
|
_selectionStorePath = Path.Combine(_serverUploadFolder, "hr-kpi-variants.json");
|
||||||
|
_selectionStore = await ReadSelectionStoreAsync();
|
||||||
|
_dataFolder = _serverUploadFolder;
|
||||||
|
if (_selectionStore.LastSelection is not null)
|
||||||
|
ApplySelectionState(_selectionStore.LastSelection);
|
||||||
|
else
|
||||||
|
_mitarbeitertyp = "Festangestellt";
|
||||||
|
RefreshVariantNames();
|
||||||
if (CanShowHrKpi)
|
if (CanShowHrKpi)
|
||||||
{
|
{
|
||||||
await LoadAsync();
|
await LoadAsync();
|
||||||
@@ -241,6 +341,8 @@ else
|
|||||||
SearchText = _searchText,
|
SearchText = _searchText,
|
||||||
ManagementView = _managementView
|
ManagementView = _managementView
|
||||||
});
|
});
|
||||||
|
_selectionStore.LastSelection = CreateSelectionState();
|
||||||
|
await WriteSelectionStoreAsync();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -252,8 +354,243 @@ else
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task UploadHrKpiFilesAsync(InputFileChangeEventArgs args)
|
||||||
|
{
|
||||||
|
if (!CanShowHrKpi)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_uploading = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_serverUploadFolder);
|
||||||
|
var uploaded = 0;
|
||||||
|
var skipped = new List<string>();
|
||||||
|
var expected = ExpectedUploadFileNames.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var file in args.GetMultipleFiles(10))
|
||||||
|
{
|
||||||
|
var fileName = Path.GetFileName(file.Name);
|
||||||
|
if (!expected.Contains(fileName))
|
||||||
|
{
|
||||||
|
skipped.Add(fileName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetPath = Path.Combine(_serverUploadFolder, fileName);
|
||||||
|
await using var source = file.OpenReadStream(50 * 1024 * 1024);
|
||||||
|
await using var target = File.Create(targetPath);
|
||||||
|
await source.CopyToAsync(target);
|
||||||
|
uploaded++;
|
||||||
|
}
|
||||||
|
|
||||||
|
_dataFolder = _serverUploadFolder;
|
||||||
|
|
||||||
|
if (uploaded > 0)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"{uploaded} HR-KPI-Datei(en) auf den Server geladen.", Severity.Success);
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipped.Count > 0)
|
||||||
|
Snackbar.Add($"Nicht uebernommen, weil Dateiname nicht erwartet wird: {string.Join(", ", skipped)}", Severity.Warning);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Upload fehlgeschlagen: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_uploading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UseServerUploadFolderAsync()
|
||||||
|
{
|
||||||
|
_dataFolder = _serverUploadFolder;
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveVariantAsync()
|
||||||
|
{
|
||||||
|
var name = (_variantName ?? _selectedVariantName)?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Bitte Variantenname eingeben.", "Please enter a variant name."), Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectionStore = await ReadSelectionStoreAsync();
|
||||||
|
_selectionStore.Variants[name] = CreateSelectionState();
|
||||||
|
await WriteSelectionStoreAsync();
|
||||||
|
RefreshVariantNames();
|
||||||
|
_selectedVariantName = name;
|
||||||
|
_variantName = name;
|
||||||
|
Snackbar.Add($"{T("Variante gespeichert", "Variant saved")}: {name}", Severity.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpdateSelectedVariantAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_selectedVariantName))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var name = _selectedVariantName.Trim();
|
||||||
|
_selectionStore = await ReadSelectionStoreAsync();
|
||||||
|
_selectionStore.Variants[name] = CreateSelectionState();
|
||||||
|
await WriteSelectionStoreAsync();
|
||||||
|
RefreshVariantNames();
|
||||||
|
_selectedVariantName = name;
|
||||||
|
_variantName = name;
|
||||||
|
Snackbar.Add($"{T("Variante aktualisiert", "Variant updated")}: {name}", Severity.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RenameVariantAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_selectedVariantName) || string.IsNullOrWhiteSpace(_variantName))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var oldName = _selectedVariantName.Trim();
|
||||||
|
var newName = _variantName.Trim();
|
||||||
|
if (string.Equals(oldName, newName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Der Variantenname ist unverändert.", "The variant name is unchanged."), Severity.Info);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectionStore = await ReadSelectionStoreAsync();
|
||||||
|
if (!_selectionStore.Variants.TryGetValue(oldName, out var selection))
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Variante nicht gefunden.", "Variant not found."), Severity.Warning);
|
||||||
|
RefreshVariantNames();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectionStore.Variants.Remove(oldName);
|
||||||
|
_selectionStore.Variants[newName] = selection;
|
||||||
|
await WriteSelectionStoreAsync();
|
||||||
|
RefreshVariantNames();
|
||||||
|
_selectedVariantName = newName;
|
||||||
|
_variantName = newName;
|
||||||
|
Snackbar.Add($"{T("Variante umbenannt", "Variant renamed")}: {oldName} -> {newName}", Severity.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadVariantAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_selectedVariantName))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_selectionStore = await ReadSelectionStoreAsync();
|
||||||
|
if (!_selectionStore.Variants.TryGetValue(_selectedVariantName.Trim(), out var selection))
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Variante nicht gefunden.", "Variant not found."), Severity.Warning);
|
||||||
|
RefreshVariantNames();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplySelectionState(selection);
|
||||||
|
_variantName = _selectedVariantName;
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteVariantAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_selectedVariantName))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var name = _selectedVariantName.Trim();
|
||||||
|
_selectionStore = await ReadSelectionStoreAsync();
|
||||||
|
_selectionStore.Variants.Remove(name);
|
||||||
|
await WriteSelectionStoreAsync();
|
||||||
|
RefreshVariantNames();
|
||||||
|
_selectedVariantName = null;
|
||||||
|
_variantName = null;
|
||||||
|
Snackbar.Add($"{T("Variante gelöscht", "Variant deleted")}: {name}", Severity.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshVariantNames()
|
||||||
|
{
|
||||||
|
_variantNames = _selectionStore.Variants.Keys
|
||||||
|
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private HrKpiSelectionState CreateSelectionState()
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
DataFolder = _dataFolder,
|
||||||
|
Year = _year,
|
||||||
|
FromDate = _fromDate,
|
||||||
|
ToDate = _toDate,
|
||||||
|
EntryYear = _entryYear,
|
||||||
|
Organisation = _organisation,
|
||||||
|
Kostenstelle = _kostenstelle,
|
||||||
|
Mitarbeitertyp = _mitarbeitertyp,
|
||||||
|
FluktuationFilter = _fluktuationFilter,
|
||||||
|
GlzAmpel = _glzAmpel,
|
||||||
|
RestferienAmpel = _restferienAmpel,
|
||||||
|
SearchText = _searchText,
|
||||||
|
ManagementView = _managementView
|
||||||
|
};
|
||||||
|
|
||||||
|
private void ApplySelectionState(HrKpiSelectionState state)
|
||||||
|
{
|
||||||
|
_dataFolder = string.IsNullOrWhiteSpace(state.DataFolder) ? _serverUploadFolder : state.DataFolder;
|
||||||
|
_year = state.Year;
|
||||||
|
_fromDate = state.FromDate;
|
||||||
|
_toDate = state.ToDate;
|
||||||
|
_entryYear = state.EntryYear;
|
||||||
|
_organisation = state.Organisation;
|
||||||
|
_kostenstelle = state.Kostenstelle;
|
||||||
|
_mitarbeitertyp = string.IsNullOrWhiteSpace(state.Mitarbeitertyp) ? "Festangestellt" : state.Mitarbeitertyp;
|
||||||
|
_fluktuationFilter = string.IsNullOrWhiteSpace(state.FluktuationFilter) ? "Alle" : state.FluktuationFilter;
|
||||||
|
_glzAmpel = state.GlzAmpel;
|
||||||
|
_restferienAmpel = state.RestferienAmpel;
|
||||||
|
_searchText = state.SearchText;
|
||||||
|
_managementView = state.ManagementView;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<HrKpiSelectionStore> ReadSelectionStoreAsync()
|
||||||
|
{
|
||||||
|
await SelectionStoreLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(_selectionStorePath))
|
||||||
|
return new HrKpiSelectionStore();
|
||||||
|
|
||||||
|
await using var stream = File.OpenRead(_selectionStorePath);
|
||||||
|
return await JsonSerializer.DeserializeAsync<HrKpiSelectionStore>(stream) ?? new HrKpiSelectionStore();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new HrKpiSelectionStore();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SelectionStoreLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WriteSelectionStoreAsync()
|
||||||
|
{
|
||||||
|
await SelectionStoreLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(_selectionStorePath) ?? _serverUploadFolder);
|
||||||
|
var options = new JsonSerializerOptions { WriteIndented = true };
|
||||||
|
await using var stream = File.Create(_selectionStorePath);
|
||||||
|
await JsonSerializer.SerializeAsync(stream, _selectionStore, options);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SelectionStoreLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task UnlockHrKpiAsync()
|
private async Task UnlockHrKpiAsync()
|
||||||
{
|
{
|
||||||
|
_unlockClickCount++;
|
||||||
|
|
||||||
if (!HrKpiAccess.TryUnlock(_hrUsername ?? string.Empty, _hrPassword ?? string.Empty))
|
if (!HrKpiAccess.TryUnlock(_hrUsername ?? string.Empty, _hrPassword ?? string.Empty))
|
||||||
{
|
{
|
||||||
Snackbar.Add(T("HR-KPI-Anmeldung fehlgeschlagen.", "HR KPI sign-in failed."), Severity.Error);
|
Snackbar.Add(T("HR-KPI-Anmeldung fehlgeschlagen.", "HR KPI sign-in failed."), Severity.Error);
|
||||||
@@ -306,4 +643,27 @@ else
|
|||||||
private bool CanShowHrKpi => !HrKpiAccess.IsEnabled || HrKpiAccess.IsUnlocked;
|
private bool CanShowHrKpi => !HrKpiAccess.IsEnabled || HrKpiAccess.IsUnlocked;
|
||||||
|
|
||||||
private string T(string german, string english) => UiText.Text(german, english);
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
|
||||||
|
private sealed class HrKpiSelectionStore
|
||||||
|
{
|
||||||
|
public HrKpiSelectionState? LastSelection { get; set; }
|
||||||
|
public Dictionary<string, HrKpiSelectionState> Variants { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class HrKpiSelectionState
|
||||||
|
{
|
||||||
|
public string? DataFolder { get; set; }
|
||||||
|
public int? Year { get; set; }
|
||||||
|
public DateTime? FromDate { get; set; }
|
||||||
|
public DateTime? ToDate { get; set; }
|
||||||
|
public int? EntryYear { get; set; }
|
||||||
|
public string? Organisation { get; set; }
|
||||||
|
public string? Kostenstelle { get; set; }
|
||||||
|
public string? Mitarbeitertyp { get; set; }
|
||||||
|
public string? FluktuationFilter { get; set; }
|
||||||
|
public string? GlzAmpel { get; set; }
|
||||||
|
public string? RestferienAmpel { get; set; }
|
||||||
|
public string? SearchText { get; set; }
|
||||||
|
public bool ManagementView { get; set; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@page "/hr-kpi/schulung"
|
@page "/hr-kpi/schulung"
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
@inject TrafagSalesExporter.Services.IUiTextService UiText
|
@inject TrafagSalesExporter.Services.IUiTextService UiText
|
||||||
|
|
||||||
<PageTitle>@T("HR KPI Schulung", "HR KPI training")</PageTitle>
|
<PageTitle>@T("HR KPI Schulung", "HR KPI training")</PageTitle>
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
@page "/diagnostics/interactive"
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
|
@inject ILogger<InteractiveDiagnostics> Logger
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
|
<PageTitle>Interaktivitaet Diagnose</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-4">Interaktivitaet Diagnose</MudText>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4" Elevation="1" Style="max-width:760px;">
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined">
|
||||||
|
HTML wurde vom Server gerendert.
|
||||||
|
</MudAlert>
|
||||||
|
|
||||||
|
<MudText>Adresse: @Navigation.Uri</MudText>
|
||||||
|
<MudText>Blazor interaktiv verbunden: @(_interactive ? "JA" : "NEIN")</MudText>
|
||||||
|
<MudText>Server-Klicks angekommen: @_clickCount</MudText>
|
||||||
|
<MudText>JavaScript Diagnose: <span id="js-diagnostic-status">nicht ausgefuehrt</span></MudText>
|
||||||
|
<MudText>Blazor Objekt im Browser: <span id="blazor-diagnostic-status">unbekannt</span></MudText>
|
||||||
|
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="RegisterClick">
|
||||||
|
Server-Klick testen
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var jsStatus = document.getElementById('js-diagnostic-status');
|
||||||
|
var blazorStatus = document.getElementById('blazor-diagnostic-status');
|
||||||
|
if (jsStatus) {
|
||||||
|
jsStatus.textContent = 'ausgefuehrt';
|
||||||
|
}
|
||||||
|
if (blazorStatus) {
|
||||||
|
blazorStatus.textContent = window.Blazor ? 'vorhanden' : 'fehlt';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private bool _interactive;
|
||||||
|
private int _clickCount;
|
||||||
|
|
||||||
|
protected override void OnAfterRender(bool firstRender)
|
||||||
|
{
|
||||||
|
if (!firstRender)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_interactive = true;
|
||||||
|
Logger.LogInformation("Interactive diagnostics became interactive for {Uri}", Navigation.Uri);
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RegisterClick()
|
||||||
|
{
|
||||||
|
_clickCount++;
|
||||||
|
Logger.LogInformation("Interactive diagnostics server click received. Count={ClickCount}", _clickCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
@page "/logs"
|
@page "/logs"
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
@using TrafagSalesExporter.Services
|
@using TrafagSalesExporter.Services
|
||||||
@inject ILogsPageService LogsPageActions
|
@inject ILogsPageService LogsPageActions
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@page "/management-cockpit"
|
@page "/management-cockpit"
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
@using TrafagSalesExporter.Models
|
@using TrafagSalesExporter.Models
|
||||||
@using TrafagSalesExporter.Services
|
@using TrafagSalesExporter.Services
|
||||||
@inject IManagementCockpitPageService CockpitPageService
|
@inject IManagementCockpitPageService CockpitPageService
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@page "/manual-imports"
|
@page "/manual-imports"
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using TrafagSalesExporter.Data
|
@using TrafagSalesExporter.Data
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@page "/settings"
|
@page "/settings"
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
|
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
|
||||||
@using TrafagSalesExporter.Models
|
@using TrafagSalesExporter.Models
|
||||||
@using TrafagSalesExporter.Services
|
@using TrafagSalesExporter.Services
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@page "/source-viewer"
|
@page "/source-viewer"
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
@using Microsoft.AspNetCore.Components
|
@using Microsoft.AspNetCore.Components
|
||||||
@using Microsoft.AspNetCore.WebUtilities
|
@using Microsoft.AspNetCore.WebUtilities
|
||||||
@inject IWebHostEnvironment Environment
|
@inject IWebHostEnvironment Environment
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@page "/standorte"
|
@page "/standorte"
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
|
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
@using System.Text.Json
|
@using System.Text.Json
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@page "/transformations"
|
@page "/transformations"
|
||||||
|
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
|
||||||
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
|
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
|
||||||
@using System.Reflection
|
@using System.Reflection
|
||||||
@using TrafagSalesExporter.Models
|
@using TrafagSalesExporter.Models
|
||||||
|
|||||||
@@ -184,6 +184,8 @@ public sealed class HrAbsenceRow
|
|||||||
public string Organisationseinheit { get; set; } = string.Empty;
|
public string Organisationseinheit { get; set; } = string.Empty;
|
||||||
public string Stelle { get; set; } = string.Empty;
|
public string Stelle { get; set; } = string.Empty;
|
||||||
public string Status { get; set; } = string.Empty;
|
public string Status { get; set; } = string.Empty;
|
||||||
|
public DateTime? VonDatum { get; set; }
|
||||||
|
public DateTime? BisDatum { get; set; }
|
||||||
public decimal KrankheitKurzStd { get; set; }
|
public decimal KrankheitKurzStd { get; set; }
|
||||||
public decimal KrankheitLangStd { get; set; }
|
public decimal KrankheitLangStd { get; set; }
|
||||||
public decimal KrankheitGesamtStd { get; set; }
|
public decimal KrankheitGesamtStd { get; set; }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authentication;
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Server.IISIntegration;
|
using Microsoft.AspNetCore.Server.IISIntegration;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using MudBlazor.Services;
|
using MudBlazor.Services;
|
||||||
using TrafagSalesExporter.Data;
|
using TrafagSalesExporter.Data;
|
||||||
using TrafagSalesExporter.Models;
|
using TrafagSalesExporter.Models;
|
||||||
@@ -139,7 +140,74 @@ app.UseAuthentication();
|
|||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.UseAntiforgery();
|
app.UseAntiforgery();
|
||||||
|
|
||||||
|
app.MapPost("/access/finance", async (HttpContext httpContext, IOptions<FinanceCockpitAccessOptions> options) =>
|
||||||
|
{
|
||||||
|
var form = await httpContext.Request.ReadFormAsync();
|
||||||
|
var settings = options.Value;
|
||||||
|
var username = form["username"].ToString();
|
||||||
|
var password = form["password"].ToString();
|
||||||
|
if (MatchesAccess(settings.Enabled, settings.Username, settings.PasswordHash, settings.Password, username, password))
|
||||||
|
AccessUnlockCookie.SetUnlocked(httpContext, AccessUnlockCookie.FinanceCookieName, settings.PasswordHash);
|
||||||
|
|
||||||
|
return Results.Redirect(ResolveReturnUrl(httpContext, form["returnUrl"].ToString()));
|
||||||
|
}).DisableAntiforgery();
|
||||||
|
|
||||||
|
app.MapPost("/access/admin", async (HttpContext httpContext, IOptions<AdminAccessOptions> options) =>
|
||||||
|
{
|
||||||
|
var form = await httpContext.Request.ReadFormAsync();
|
||||||
|
var settings = options.Value;
|
||||||
|
var username = form["username"].ToString();
|
||||||
|
var password = form["password"].ToString();
|
||||||
|
if (MatchesAccess(settings.Enabled, settings.Username, settings.PasswordHash, settings.Password, username, password))
|
||||||
|
AccessUnlockCookie.SetUnlocked(httpContext, AccessUnlockCookie.AdminCookieName, settings.PasswordHash);
|
||||||
|
|
||||||
|
return Results.Redirect(ResolveReturnUrl(httpContext, form["returnUrl"].ToString()));
|
||||||
|
}).DisableAntiforgery();
|
||||||
|
|
||||||
|
app.MapPost("/access/hr", async (HttpContext httpContext, IOptions<HrKpiAccessOptions> options) =>
|
||||||
|
{
|
||||||
|
var form = await httpContext.Request.ReadFormAsync();
|
||||||
|
var settings = options.Value;
|
||||||
|
var username = form["username"].ToString();
|
||||||
|
var password = form["password"].ToString();
|
||||||
|
if (MatchesAccess(settings.Enabled, settings.Username, settings.PasswordHash, settings.Password, username, password))
|
||||||
|
AccessUnlockCookie.SetUnlocked(httpContext, AccessUnlockCookie.HrCookieName, settings.PasswordHash);
|
||||||
|
|
||||||
|
return Results.Redirect(ResolveReturnUrl(httpContext, form["returnUrl"].ToString()));
|
||||||
|
}).DisableAntiforgery();
|
||||||
|
|
||||||
app.MapRazorComponents<TrafagSalesExporter.Components.App>()
|
app.MapRazorComponents<TrafagSalesExporter.Components.App>()
|
||||||
.AddInteractiveServerRenderMode();
|
.AddInteractiveServerRenderMode();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
static bool MatchesAccess(bool enabled, string configuredUsername, string configuredHash, string configuredPassword, string username, string password)
|
||||||
|
{
|
||||||
|
if (!enabled)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(username) ||
|
||||||
|
string.IsNullOrEmpty(password) ||
|
||||||
|
!string.Equals(username.Trim(), configuredUsername.Trim(), StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !string.IsNullOrWhiteSpace(configuredHash)
|
||||||
|
? string.Equals(AccessPasswordSettingsWriter.HashPassword(password), configuredHash.Trim(), StringComparison.Ordinal)
|
||||||
|
: string.Equals(password, configuredPassword, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
static string ResolveReturnUrl(HttpContext httpContext, string returnUrl)
|
||||||
|
{
|
||||||
|
if (Uri.TryCreate(returnUrl, UriKind.Absolute, out var absolute) &&
|
||||||
|
string.Equals(absolute.Host, httpContext.Request.Host.Host, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return absolute.PathAndQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Uri.TryCreate(returnUrl, UriKind.Relative, out _))
|
||||||
|
return returnUrl;
|
||||||
|
|
||||||
|
return $"{httpContext.Request.PathBase}/";
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
internal static class AccessUnlockCookie
|
||||||
|
{
|
||||||
|
public const string FinanceCookieName = "TrafagFinanceUnlocked";
|
||||||
|
public const string AdminCookieName = "TrafagAdminUnlocked";
|
||||||
|
public const string HrCookieName = "TrafagHrUnlocked";
|
||||||
|
|
||||||
|
public static bool IsUnlocked(HttpContext? httpContext, string cookieName, string passwordHash)
|
||||||
|
{
|
||||||
|
if (httpContext is null ||
|
||||||
|
string.IsNullOrWhiteSpace(passwordHash) ||
|
||||||
|
!httpContext.Request.Cookies.TryGetValue(cookieName, out var value))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CryptographicOperations.FixedTimeEquals(
|
||||||
|
Encoding.UTF8.GetBytes(value),
|
||||||
|
Encoding.UTF8.GetBytes(CreateValue(cookieName, passwordHash)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void SetUnlocked(HttpContext httpContext, string cookieName, string passwordHash)
|
||||||
|
{
|
||||||
|
httpContext.Response.Cookies.Append(cookieName, CreateValue(cookieName, passwordHash), new CookieOptions
|
||||||
|
{
|
||||||
|
HttpOnly = true,
|
||||||
|
IsEssential = true,
|
||||||
|
SameSite = SameSiteMode.Strict,
|
||||||
|
Secure = httpContext.Request.IsHttps,
|
||||||
|
Path = string.IsNullOrWhiteSpace(httpContext.Request.PathBase) ? "/" : httpContext.Request.PathBase.Value!,
|
||||||
|
Expires = DateTimeOffset.UtcNow.AddHours(12)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateValue(string cookieName, string passwordHash)
|
||||||
|
{
|
||||||
|
var input = $"TrafagSalesExporter|{cookieName}|{passwordHash.Trim()}";
|
||||||
|
return AccessPasswordSettingsWriter.HashPassword(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,11 +19,19 @@ public sealed class AdminAccessService : IAdminAccessService
|
|||||||
{
|
{
|
||||||
private readonly AdminAccessOptions _options;
|
private readonly AdminAccessOptions _options;
|
||||||
private readonly IHostEnvironment _environment;
|
private readonly IHostEnvironment _environment;
|
||||||
|
private readonly ILogger<AdminAccessService> _logger;
|
||||||
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||||
|
|
||||||
public AdminAccessService(IOptions<AdminAccessOptions> options, IHostEnvironment environment)
|
public AdminAccessService(
|
||||||
|
IOptions<AdminAccessOptions> options,
|
||||||
|
IHostEnvironment environment,
|
||||||
|
ILogger<AdminAccessService> logger,
|
||||||
|
IHttpContextAccessor httpContextAccessor)
|
||||||
{
|
{
|
||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
_environment = environment;
|
_environment = environment;
|
||||||
|
_logger = logger;
|
||||||
|
_httpContextAccessor = httpContextAccessor;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsEnabled => _options.Enabled;
|
public bool IsEnabled => _options.Enabled;
|
||||||
@@ -33,13 +41,21 @@ public sealed class AdminAccessService : IAdminAccessService
|
|||||||
!string.IsNullOrWhiteSpace(_options.Username) &&
|
!string.IsNullOrWhiteSpace(_options.Username) &&
|
||||||
(!string.IsNullOrWhiteSpace(_options.PasswordHash) || !string.IsNullOrEmpty(_options.Password));
|
(!string.IsNullOrWhiteSpace(_options.PasswordHash) || !string.IsNullOrEmpty(_options.Password));
|
||||||
|
|
||||||
public bool IsUnlocked { get; private set; }
|
public bool IsUnlocked =>
|
||||||
|
_isUnlocked ||
|
||||||
|
AccessUnlockCookie.IsUnlocked(
|
||||||
|
_httpContextAccessor.HttpContext,
|
||||||
|
AccessUnlockCookie.AdminCookieName,
|
||||||
|
_options.PasswordHash);
|
||||||
|
|
||||||
|
private bool _isUnlocked;
|
||||||
|
|
||||||
public bool TryUnlock(string username, string password)
|
public bool TryUnlock(string username, string password)
|
||||||
{
|
{
|
||||||
if (!IsEnabled)
|
if (!IsEnabled)
|
||||||
{
|
{
|
||||||
IsUnlocked = true;
|
_isUnlocked = true;
|
||||||
|
_logger.LogInformation("Admin access unlocked because AdminAccess is disabled.");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +64,12 @@ public sealed class AdminAccessService : IAdminAccessService
|
|||||||
string.IsNullOrEmpty(password) ||
|
string.IsNullOrEmpty(password) ||
|
||||||
!FixedEquals(username.Trim(), _options.Username.Trim()))
|
!FixedEquals(username.Trim(), _options.Username.Trim()))
|
||||||
{
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Admin access unlock failed before password check. IsConfigured={IsConfigured}, HasUsername={HasUsername}, PasswordLength={PasswordLength}, UsernameMatches={UsernameMatches}",
|
||||||
|
IsConfigured,
|
||||||
|
!string.IsNullOrWhiteSpace(username),
|
||||||
|
password?.Length ?? 0,
|
||||||
|
!string.IsNullOrWhiteSpace(username) && FixedEquals(username.Trim(), _options.Username.Trim()));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +77,14 @@ public sealed class AdminAccessService : IAdminAccessService
|
|||||||
? VerifyPasswordHash(password, _options.PasswordHash)
|
? VerifyPasswordHash(password, _options.PasswordHash)
|
||||||
: FixedEquals(password, _options.Password);
|
: FixedEquals(password, _options.Password);
|
||||||
|
|
||||||
IsUnlocked = valid;
|
_isUnlocked = valid;
|
||||||
|
_logger.Log(
|
||||||
|
valid ? LogLevel.Information : LogLevel.Warning,
|
||||||
|
"Admin access password check completed. Success={Success}, Username={Username}, PasswordLength={PasswordLength}, UsesHash={UsesHash}",
|
||||||
|
valid,
|
||||||
|
username.Trim(),
|
||||||
|
password.Length,
|
||||||
|
!string.IsNullOrWhiteSpace(_options.PasswordHash));
|
||||||
return valid;
|
return valid;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,11 +103,11 @@ public sealed class AdminAccessService : IAdminAccessService
|
|||||||
AccessPasswordSettingsWriter.SavePasswordHash(_environment.ContentRootPath, AdminAccessOptions.SectionName, passwordHash);
|
AccessPasswordSettingsWriter.SavePasswordHash(_environment.ContentRootPath, AdminAccessOptions.SectionName, passwordHash);
|
||||||
_options.PasswordHash = passwordHash;
|
_options.PasswordHash = passwordHash;
|
||||||
_options.Password = string.Empty;
|
_options.Password = string.Empty;
|
||||||
IsUnlocked = true;
|
_isUnlocked = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Lock() => IsUnlocked = false;
|
public void Lock() => _isUnlocked = false;
|
||||||
|
|
||||||
private static bool VerifyPasswordHash(string password, string configuredHash)
|
private static bool VerifyPasswordHash(string password, string configuredHash)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,18 +21,21 @@ public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService,
|
|||||||
private readonly IHostEnvironment _environment;
|
private readonly IHostEnvironment _environment;
|
||||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||||
private readonly IAccessSessionTracker _sessionTracker;
|
private readonly IAccessSessionTracker _sessionTracker;
|
||||||
|
private readonly ILogger<FinanceCockpitAccessService> _logger;
|
||||||
private readonly string _sessionId = Guid.NewGuid().ToString("N");
|
private readonly string _sessionId = Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
public FinanceCockpitAccessService(
|
public FinanceCockpitAccessService(
|
||||||
IOptions<FinanceCockpitAccessOptions> options,
|
IOptions<FinanceCockpitAccessOptions> options,
|
||||||
IHostEnvironment environment,
|
IHostEnvironment environment,
|
||||||
IHttpContextAccessor httpContextAccessor,
|
IHttpContextAccessor httpContextAccessor,
|
||||||
IAccessSessionTracker sessionTracker)
|
IAccessSessionTracker sessionTracker,
|
||||||
|
ILogger<FinanceCockpitAccessService> logger)
|
||||||
{
|
{
|
||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
_environment = environment;
|
_environment = environment;
|
||||||
_httpContextAccessor = httpContextAccessor;
|
_httpContextAccessor = httpContextAccessor;
|
||||||
_sessionTracker = sessionTracker;
|
_sessionTracker = sessionTracker;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsEnabled => _options.Enabled;
|
public bool IsEnabled => _options.Enabled;
|
||||||
@@ -42,13 +45,21 @@ public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService,
|
|||||||
!string.IsNullOrWhiteSpace(_options.Username) &&
|
!string.IsNullOrWhiteSpace(_options.Username) &&
|
||||||
(!string.IsNullOrWhiteSpace(_options.PasswordHash) || !string.IsNullOrEmpty(_options.Password));
|
(!string.IsNullOrWhiteSpace(_options.PasswordHash) || !string.IsNullOrEmpty(_options.Password));
|
||||||
|
|
||||||
public bool IsUnlocked { get; private set; }
|
public bool IsUnlocked =>
|
||||||
|
_isUnlocked ||
|
||||||
|
AccessUnlockCookie.IsUnlocked(
|
||||||
|
_httpContextAccessor.HttpContext,
|
||||||
|
AccessUnlockCookie.FinanceCookieName,
|
||||||
|
_options.PasswordHash);
|
||||||
|
|
||||||
|
private bool _isUnlocked;
|
||||||
|
|
||||||
public bool TryUnlock(string username, string password)
|
public bool TryUnlock(string username, string password)
|
||||||
{
|
{
|
||||||
if (!IsEnabled)
|
if (!IsEnabled)
|
||||||
{
|
{
|
||||||
IsUnlocked = true;
|
_isUnlocked = true;
|
||||||
|
_logger.LogInformation("Finance Cockpit access unlocked because FinanceCockpitAccess is disabled.");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +68,12 @@ public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService,
|
|||||||
string.IsNullOrEmpty(password) ||
|
string.IsNullOrEmpty(password) ||
|
||||||
!FixedEquals(username.Trim(), _options.Username.Trim()))
|
!FixedEquals(username.Trim(), _options.Username.Trim()))
|
||||||
{
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Finance Cockpit unlock failed before password check. IsConfigured={IsConfigured}, HasUsername={HasUsername}, PasswordLength={PasswordLength}, UsernameMatches={UsernameMatches}",
|
||||||
|
IsConfigured,
|
||||||
|
!string.IsNullOrWhiteSpace(username),
|
||||||
|
password?.Length ?? 0,
|
||||||
|
!string.IsNullOrWhiteSpace(username) && FixedEquals(username.Trim(), _options.Username.Trim()));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +81,14 @@ public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService,
|
|||||||
? VerifyPasswordHash(password, _options.PasswordHash)
|
? VerifyPasswordHash(password, _options.PasswordHash)
|
||||||
: FixedEquals(password, _options.Password);
|
: FixedEquals(password, _options.Password);
|
||||||
|
|
||||||
IsUnlocked = valid;
|
_isUnlocked = valid;
|
||||||
|
_logger.Log(
|
||||||
|
valid ? LogLevel.Information : LogLevel.Warning,
|
||||||
|
"Finance Cockpit password check completed. Success={Success}, Username={Username}, PasswordLength={PasswordLength}, UsesHash={UsesHash}",
|
||||||
|
valid,
|
||||||
|
username.Trim(),
|
||||||
|
password.Length,
|
||||||
|
!string.IsNullOrWhiteSpace(_options.PasswordHash));
|
||||||
if (valid)
|
if (valid)
|
||||||
_sessionTracker.Register(_sessionId, "Finance Cockpit", username.Trim(), GetRemoteAddress());
|
_sessionTracker.Register(_sessionId, "Finance Cockpit", username.Trim(), GetRemoteAddress());
|
||||||
return valid;
|
return valid;
|
||||||
@@ -72,7 +96,7 @@ public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService,
|
|||||||
|
|
||||||
public void Lock()
|
public void Lock()
|
||||||
{
|
{
|
||||||
IsUnlocked = false;
|
_isUnlocked = false;
|
||||||
_sessionTracker.Unregister(_sessionId);
|
_sessionTracker.Unregister(_sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +115,7 @@ public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService,
|
|||||||
AccessPasswordSettingsWriter.SavePasswordHash(_environment.ContentRootPath, FinanceCockpitAccessOptions.SectionName, passwordHash);
|
AccessPasswordSettingsWriter.SavePasswordHash(_environment.ContentRootPath, FinanceCockpitAccessOptions.SectionName, passwordHash);
|
||||||
_options.PasswordHash = passwordHash;
|
_options.PasswordHash = passwordHash;
|
||||||
_options.Password = string.Empty;
|
_options.Password = string.Empty;
|
||||||
IsUnlocked = true;
|
_isUnlocked = true;
|
||||||
_sessionTracker.Register(_sessionId, "Finance Cockpit", username.Trim(), GetRemoteAddress());
|
_sessionTracker.Register(_sessionId, "Finance Cockpit", username.Trim(), GetRemoteAddress());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ internal sealed class HrKpiDashboardBuilder
|
|||||||
|
|
||||||
var turnoverEmployees = ApplyTurnoverEmployeeFilters(employees, normalizedOptions).ToList();
|
var turnoverEmployees = ApplyTurnoverEmployeeFilters(employees, normalizedOptions).ToList();
|
||||||
var turnoverHeadcountLeavers = ApplyTurnoverHeadcountLeaverFilters(leavers, normalizedOptions).ToList();
|
var turnoverHeadcountLeavers = ApplyTurnoverHeadcountLeaverFilters(leavers, normalizedOptions).ToList();
|
||||||
|
var analysisPeriod = ResolveAnalysisPeriod(normalizedOptions);
|
||||||
var filteredEmployees = ApplyEmployeeFilters(employees, normalizedOptions).ToList();
|
var filteredEmployees = ApplyEmployeeFilters(employees, normalizedOptions).ToList();
|
||||||
var filteredEmployeeNumbers = filteredEmployees
|
var filteredEmployeeNumbers = filteredEmployees
|
||||||
.Where(x => x.Personalnummer.HasValue)
|
.Where(x => x.Personalnummer.HasValue)
|
||||||
@@ -97,6 +98,7 @@ internal sealed class HrKpiDashboardBuilder
|
|||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
|
|
||||||
employees = filteredEmployees;
|
employees = filteredEmployees;
|
||||||
|
var absenceRowsWithoutDates = absences.Count(x => !x.VonDatum.HasValue && !x.BisDatum.HasValue);
|
||||||
absences = ApplyAbsenceFilters(absences, normalizedOptions, filteredEmployeeNumbers).ToList();
|
absences = ApplyAbsenceFilters(absences, normalizedOptions, filteredEmployeeNumbers).ToList();
|
||||||
leavers = ApplyLeaverFilters(leavers, normalizedOptions).ToList();
|
leavers = ApplyLeaverFilters(leavers, normalizedOptions).ToList();
|
||||||
var turnoverPeriod = ResolveTurnoverPeriodScope(normalizedOptions, leavers);
|
var turnoverPeriod = ResolveTurnoverPeriodScope(normalizedOptions, leavers);
|
||||||
@@ -104,9 +106,9 @@ internal sealed class HrKpiDashboardBuilder
|
|||||||
result.Employees = employees;
|
result.Employees = employees;
|
||||||
result.Absences = absences;
|
result.Absences = absences;
|
||||||
result.Leavers = leavers;
|
result.Leavers = leavers;
|
||||||
result.Metrics = BuildOverviewMetrics(employees, absences, turnoverEmployees, turnoverHeadcountLeavers, leavers, turnoverPeriod);
|
result.Metrics = BuildOverviewMetrics(employees, absences, turnoverEmployees, turnoverHeadcountLeavers, leavers, turnoverPeriod, analysisPeriod);
|
||||||
result.TurnoverMetrics = BuildTurnoverMetrics(turnoverEmployees, turnoverHeadcountLeavers, leavers, turnoverPeriod);
|
result.TurnoverMetrics = BuildTurnoverMetrics(turnoverEmployees, turnoverHeadcountLeavers, leavers, turnoverPeriod);
|
||||||
result.AbsenceMetrics = BuildAbsenceMetrics(employees, absences);
|
result.AbsenceMetrics = BuildAbsenceMetrics(employees, absences, analysisPeriod);
|
||||||
result.TimeVacationMetrics = BuildTimeVacationMetrics(employees);
|
result.TimeVacationMetrics = BuildTimeVacationMetrics(employees);
|
||||||
result.PeriodComparisonMetrics = BuildPeriodComparisonMetrics(turnoverEmployees, turnoverHeadcountLeavers, leavers, turnoverPeriod);
|
result.PeriodComparisonMetrics = BuildPeriodComparisonMetrics(turnoverEmployees, turnoverHeadcountLeavers, leavers, turnoverPeriod);
|
||||||
result.TrafficLights = BuildTrafficLights(result.Metrics, result.TurnoverMetrics, result.AbsenceMetrics, result.TimeVacationMetrics, context);
|
result.TrafficLights = BuildTrafficLights(result.Metrics, result.TurnoverMetrics, result.AbsenceMetrics, result.TimeVacationMetrics, context);
|
||||||
@@ -158,6 +160,8 @@ internal sealed class HrKpiDashboardBuilder
|
|||||||
result.Notices.Add($"{missingFteCount:N0} aktive Mitarbeitendenzeilen ohne SAP-Beschaeftigungsgrad verwenden einen FTE-Fallback aus Rexx-Arbeitszeitmodell/Sollzeit.");
|
result.Notices.Add($"{missingFteCount:N0} aktive Mitarbeitendenzeilen ohne SAP-Beschaeftigungsgrad verwenden einen FTE-Fallback aus Rexx-Arbeitszeitmodell/Sollzeit.");
|
||||||
if (HasEmployeeOnlyTurnoverFilters(normalizedOptions))
|
if (HasEmployeeOnlyTurnoverFilters(normalizedOptions))
|
||||||
result.Notices.Add("Kostenstelle, GLZ und Restferien filtern aktive Mitarbeitende und Absenzen, aber nicht die Fluktuation. Die Austrittsdatei enthaelt diese Felder nicht stabil genug fuer denselben Schnitt.");
|
result.Notices.Add("Kostenstelle, GLZ und Restferien filtern aktive Mitarbeitende und Absenzen, aber nicht die Fluktuation. Die Austrittsdatei enthaelt diese Felder nicht stabil genug fuer denselben Schnitt.");
|
||||||
|
if (analysisPeriod.HasPeriod && absenceRowsWithoutDates > 0)
|
||||||
|
result.Notices.Add("Rexx-Absenzen enthalten keine Datumsfelder. Der Zeitraumfilter setzt voraus, dass Abwesenheitinstunden.xlsx bereits fuer den gewaehlten Zeitraum exportiert wurde; die Absenzquote nutzt den gewaehlten Zeitraum als Nenner.");
|
||||||
if (!context.HasFile(_dataSources.MainFile))
|
if (!context.HasFile(_dataSources.MainFile))
|
||||||
result.Notices.Add($"Hauptdatei fehlt: {_dataSources.MainFile}. Ohne diese Datei sind keine HR-KPIs moeglich.");
|
result.Notices.Add($"Hauptdatei fehlt: {_dataSources.MainFile}. Ohne diese Datei sind keine HR-KPIs moeglich.");
|
||||||
if (!context.HasFile(_dataSources.SapFile))
|
if (!context.HasFile(_dataSources.SapFile))
|
||||||
@@ -299,6 +303,8 @@ internal sealed class HrKpiDashboardBuilder
|
|||||||
{
|
{
|
||||||
return context.ReadRows(_dataSources.AbsenceFile, "Rexx #744 Absenzen", (row, headers) =>
|
return context.ReadRows(_dataSources.AbsenceFile, "Rexx #744 Absenzen", (row, headers) =>
|
||||||
{
|
{
|
||||||
|
var fromDate = ReadDate(row, headers, "Von Datum", "Von", "Beginn", "Startdatum", "Abwesenheit von", "Datum");
|
||||||
|
var toDate = ReadDate(row, headers, "Bis Datum", "Bis", "Ende", "Enddatum", "Abwesenheit bis", "Datum");
|
||||||
var kurz = ReadDecimal(row, headers, "Krankheit angetreten (Stunden Ind.)", "Krankheit_Kurz_Std");
|
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 lang = ReadDecimal(row, headers, "Krank nicht buchbar angetreten (Stunden Ind.)", "Krankheit_Lang_Std");
|
||||||
var gesamt = kurz + lang;
|
var gesamt = kurz + lang;
|
||||||
@@ -310,6 +316,8 @@ internal sealed class HrKpiDashboardBuilder
|
|||||||
Organisationseinheit = ReadString(row, headers, "Organisation"),
|
Organisationseinheit = ReadString(row, headers, "Organisation"),
|
||||||
Stelle = ReadString(row, headers, "Stelle"),
|
Stelle = ReadString(row, headers, "Stelle"),
|
||||||
Status = ReadString(row, headers, "Personal Status", "Status"),
|
Status = ReadString(row, headers, "Personal Status", "Status"),
|
||||||
|
VonDatum = fromDate,
|
||||||
|
BisDatum = toDate ?? fromDate,
|
||||||
KrankheitKurzStd = kurz,
|
KrankheitKurzStd = kurz,
|
||||||
KrankheitLangStd = lang,
|
KrankheitLangStd = lang,
|
||||||
KrankheitGesamtStd = gesamt,
|
KrankheitGesamtStd = gesamt,
|
||||||
@@ -406,6 +414,7 @@ internal sealed class HrKpiDashboardBuilder
|
|||||||
=> rows.Where(x => MatchesFilter(x.Organisationseinheit, options.Organisationseinheit) &&
|
=> rows.Where(x => MatchesFilter(x.Organisationseinheit, options.Organisationseinheit) &&
|
||||||
x.Personalnummer.HasValue &&
|
x.Personalnummer.HasValue &&
|
||||||
filteredEmployeeNumbers.Contains(x.Personalnummer.Value) &&
|
filteredEmployeeNumbers.Contains(x.Personalnummer.Value) &&
|
||||||
|
MatchesAbsencePeriodFilter(x, options) &&
|
||||||
MatchesTextSearch(options.SearchText, x.Name, x.Personalnummer?.ToString(CultureInfo.InvariantCulture) ?? string.Empty));
|
MatchesTextSearch(options.SearchText, x.Name, x.Personalnummer?.ToString(CultureInfo.InvariantCulture) ?? string.Empty));
|
||||||
|
|
||||||
private static IEnumerable<HrLeaverRow> ApplyLeaverFilters(IEnumerable<HrLeaverRow> rows, HrKpiOptions options)
|
private static IEnumerable<HrLeaverRow> ApplyLeaverFilters(IEnumerable<HrLeaverRow> rows, HrKpiOptions options)
|
||||||
@@ -429,7 +438,8 @@ internal sealed class HrKpiDashboardBuilder
|
|||||||
IReadOnlyCollection<HrKpiEmployeeRow> turnoverEmployees,
|
IReadOnlyCollection<HrKpiEmployeeRow> turnoverEmployees,
|
||||||
IReadOnlyCollection<HrLeaverRow> turnoverHeadcountLeavers,
|
IReadOnlyCollection<HrLeaverRow> turnoverHeadcountLeavers,
|
||||||
IReadOnlyCollection<HrLeaverRow> leavers,
|
IReadOnlyCollection<HrLeaverRow> leavers,
|
||||||
TurnoverPeriodScope period)
|
TurnoverPeriodScope period,
|
||||||
|
AnalysisPeriod analysisPeriod)
|
||||||
{
|
{
|
||||||
var activeCount = CountDistinctPersons(employees.Select(x => x.Personalnummer));
|
var activeCount = CountDistinctPersons(employees.Select(x => x.Personalnummer));
|
||||||
var activeFixedCount = CountDistinctPersons(employees
|
var activeFixedCount = CountDistinctPersons(employees
|
||||||
@@ -439,7 +449,8 @@ internal sealed class HrKpiDashboardBuilder
|
|||||||
var turnoverDenominator = ResolveTurnoverDenominator(turnoverEmployees, turnoverIntervals, period);
|
var turnoverDenominator = ResolveTurnoverDenominator(turnoverEmployees, turnoverIntervals, period);
|
||||||
var fte = employees.Sum(x => x.Fte);
|
var fte = employees.Sum(x => x.Fte);
|
||||||
var sickDays = absences.Sum(x => x.KrankheitstageGesamt);
|
var sickDays = absences.Sum(x => x.KrankheitstageGesamt);
|
||||||
var absenceRate = fte <= 0 ? 0 : sickDays / (fte * 21m);
|
var absenceDenominator = fte * analysisPeriod.Workdays;
|
||||||
|
var absenceRate = absenceDenominator <= 0 ? 0 : sickDays / absenceDenominator;
|
||||||
var relevantLeavers = CountDistinctPersons(leavers.Where(x => x.IstFluktuationsrelevant).Select(x => x.Personalnummer));
|
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 employeeLeavers = CountDistinctPersons(leavers.Where(x => x.IstArbeitnehmerkuendigung).Select(x => x.Personalnummer));
|
||||||
var turnover = turnoverDenominator == 0 ? 0 : relevantLeavers / turnoverDenominator;
|
var turnover = turnoverDenominator == 0 ? 0 : relevantLeavers / turnoverDenominator;
|
||||||
@@ -558,13 +569,15 @@ internal sealed class HrKpiDashboardBuilder
|
|||||||
|
|
||||||
private static List<HrKpiMetric> BuildAbsenceMetrics(
|
private static List<HrKpiMetric> BuildAbsenceMetrics(
|
||||||
IReadOnlyCollection<HrKpiEmployeeRow> employees,
|
IReadOnlyCollection<HrKpiEmployeeRow> employees,
|
||||||
IReadOnlyCollection<HrAbsenceRow> absences)
|
IReadOnlyCollection<HrAbsenceRow> absences,
|
||||||
|
AnalysisPeriod analysisPeriod)
|
||||||
{
|
{
|
||||||
var totalSick = absences.Sum(x => x.KrankheitstageGesamt);
|
var totalSick = absences.Sum(x => x.KrankheitstageGesamt);
|
||||||
var shortSick = absences.Sum(x => x.KrankheitstageKurz);
|
var shortSick = absences.Sum(x => x.KrankheitstageKurz);
|
||||||
var longSick = absences.Sum(x => x.KrankheitstageLang);
|
var longSick = absences.Sum(x => x.KrankheitstageLang);
|
||||||
var fte = employees.Sum(x => x.Fte);
|
var fte = employees.Sum(x => x.Fte);
|
||||||
var absenceRate = fte <= 0 ? 0 : totalSick / (fte * 21m);
|
var denominator = fte * analysisPeriod.Workdays;
|
||||||
|
var absenceRate = denominator <= 0 ? 0 : totalSick / denominator;
|
||||||
var bu = employees.Sum(x => x.BuTage);
|
var bu = employees.Sum(x => x.BuTage);
|
||||||
var nbu = employees.Sum(x => x.NbuTage);
|
var nbu = employees.Sum(x => x.NbuTage);
|
||||||
|
|
||||||
@@ -573,7 +586,7 @@ internal sealed class HrKpiDashboardBuilder
|
|||||||
new() { Label = "Krankheitstage Gesamt", Value = totalSick.ToString("N1"), Detail = $"{absences.Count:N0} aktive Absenzenzeilen", Severity = absenceRate > 0.05m ? "Warning" : "Normal" },
|
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 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 = "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 / (FTE * 21 Tage)", Severity = absenceRate > 0.05m ? "Warning" : "Normal" },
|
new() { Label = "Krankenquote", Value = absenceRate.ToString("P1"), Detail = $"Krankheitstage / (FTE * {analysisPeriod.Workdays:N0} Arbeitstage), {analysisPeriod.Label}", Severity = absenceRate > 0.05m ? "Warning" : "Normal" },
|
||||||
new() { Label = "BU-Tage", Value = bu.ToString("N1"), Detail = "SAP HR KPI", Severity = "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 = "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" }
|
new() { Label = "Unfalltage Total", Value = (bu + nbu).ToString("N1"), Detail = "BU + NBU", Severity = "Normal" }
|
||||||
@@ -1052,6 +1065,23 @@ internal sealed class HrKpiDashboardBuilder
|
|||||||
(row.Austrittsjahr.HasValue && row.Austrittsjahr.Value == options.Year.Value);
|
(row.Austrittsjahr.HasValue && row.Austrittsjahr.Value == options.Year.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool MatchesAbsencePeriodFilter(HrAbsenceRow row, HrKpiOptions options)
|
||||||
|
{
|
||||||
|
var period = ResolveEmploymentPeriod(options);
|
||||||
|
if (!period.HasValue)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (!row.VonDatum.HasValue && !row.BisDatum.HasValue)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var start = row.VonDatum?.Date ?? row.BisDatum!.Value.Date;
|
||||||
|
var end = row.BisDatum?.Date ?? start;
|
||||||
|
if (end < start)
|
||||||
|
(start, end) = (end, start);
|
||||||
|
|
||||||
|
return start <= period.Value.End && end >= period.Value.Start;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool MatchesLeaverEmploymentPeriodFilter(HrLeaverRow row, HrKpiOptions options)
|
private static bool MatchesLeaverEmploymentPeriodFilter(HrLeaverRow row, HrKpiOptions options)
|
||||||
{
|
{
|
||||||
var period = ResolveEmploymentPeriod(options);
|
var period = ResolveEmploymentPeriod(options);
|
||||||
@@ -1078,6 +1108,34 @@ internal sealed class HrKpiDashboardBuilder
|
|||||||
return start <= end ? (start, end) : (end, start);
|
return start <= end ? (start, end) : (end, start);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static AnalysisPeriod ResolveAnalysisPeriod(HrKpiOptions options)
|
||||||
|
{
|
||||||
|
var period = ResolveEmploymentPeriod(options);
|
||||||
|
if (!period.HasValue)
|
||||||
|
{
|
||||||
|
return new AnalysisPeriod(null, null, 21m, "ohne Zeitraumfilter", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var workdays = CountWeekdays(period.Value.Start, period.Value.End);
|
||||||
|
var label = $"{period.Value.Start:dd.MM.yyyy} - {period.Value.End:dd.MM.yyyy}";
|
||||||
|
return new AnalysisPeriod(period.Value.Start, period.Value.End, Math.Max(1, workdays), label, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountWeekdays(DateTime start, DateTime end)
|
||||||
|
{
|
||||||
|
if (end < start)
|
||||||
|
(start, end) = (end, start);
|
||||||
|
|
||||||
|
var days = 0;
|
||||||
|
for (var date = start.Date; date <= end.Date; date = date.AddDays(1))
|
||||||
|
{
|
||||||
|
if (date.DayOfWeek is not DayOfWeek.Saturday and not DayOfWeek.Sunday)
|
||||||
|
days++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
private static int CountDistinctPersons(IEnumerable<int?> personalNumbers)
|
private static int CountDistinctPersons(IEnumerable<int?> personalNumbers)
|
||||||
=> personalNumbers
|
=> personalNumbers
|
||||||
.Where(x => x.HasValue)
|
.Where(x => x.HasValue)
|
||||||
@@ -1368,6 +1426,8 @@ internal sealed class HrKpiDashboardBuilder
|
|||||||
|
|
||||||
private sealed record TurnoverPeriodScope(int? BreakdownYear, DateTime AnchorDate, string Label, bool ShowPeriodMetrics);
|
private sealed record TurnoverPeriodScope(int? BreakdownYear, DateTime AnchorDate, string Label, bool ShowPeriodMetrics);
|
||||||
|
|
||||||
|
private sealed record AnalysisPeriod(DateTime? Start, DateTime? End, decimal Workdays, string Label, bool HasPeriod);
|
||||||
|
|
||||||
private sealed record TurnoverEmploymentInterval(int Personalnummer, DateTime? Eintrittsdatum, DateTime? Austrittsdatum);
|
private sealed record TurnoverEmploymentInterval(int Personalnummer, DateTime? Eintrittsdatum, DateTime? Austrittsdatum);
|
||||||
|
|
||||||
private sealed record TimeRow(string NameKey, DateTime? Geburtsdatum, string Arbeitszeitmodell, decimal AvgSollzeitTag);
|
private sealed record TimeRow(string NameKey, DateTime? Geburtsdatum, string Arbeitszeitmodell, decimal AvgSollzeitTag);
|
||||||
|
|||||||
@@ -42,13 +42,20 @@ public sealed class HrKpiAccessService : IHrKpiAccessService, IDisposable
|
|||||||
!string.IsNullOrWhiteSpace(_options.Username) &&
|
!string.IsNullOrWhiteSpace(_options.Username) &&
|
||||||
(!string.IsNullOrWhiteSpace(_options.PasswordHash) || !string.IsNullOrEmpty(_options.Password));
|
(!string.IsNullOrWhiteSpace(_options.PasswordHash) || !string.IsNullOrEmpty(_options.Password));
|
||||||
|
|
||||||
public bool IsUnlocked { get; private set; }
|
public bool IsUnlocked =>
|
||||||
|
_isUnlocked ||
|
||||||
|
AccessUnlockCookie.IsUnlocked(
|
||||||
|
_httpContextAccessor.HttpContext,
|
||||||
|
AccessUnlockCookie.HrCookieName,
|
||||||
|
_options.PasswordHash);
|
||||||
|
|
||||||
|
private bool _isUnlocked;
|
||||||
|
|
||||||
public bool TryUnlock(string username, string password)
|
public bool TryUnlock(string username, string password)
|
||||||
{
|
{
|
||||||
if (!IsEnabled)
|
if (!IsEnabled)
|
||||||
{
|
{
|
||||||
IsUnlocked = true;
|
_isUnlocked = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +71,7 @@ public sealed class HrKpiAccessService : IHrKpiAccessService, IDisposable
|
|||||||
? VerifyPasswordHash(password, _options.PasswordHash)
|
? VerifyPasswordHash(password, _options.PasswordHash)
|
||||||
: FixedEquals(password, _options.Password);
|
: FixedEquals(password, _options.Password);
|
||||||
|
|
||||||
IsUnlocked = valid;
|
_isUnlocked = valid;
|
||||||
if (valid)
|
if (valid)
|
||||||
_sessionTracker.Register(_sessionId, "HR KPI", username.Trim(), GetRemoteAddress());
|
_sessionTracker.Register(_sessionId, "HR KPI", username.Trim(), GetRemoteAddress());
|
||||||
return valid;
|
return valid;
|
||||||
@@ -72,7 +79,7 @@ public sealed class HrKpiAccessService : IHrKpiAccessService, IDisposable
|
|||||||
|
|
||||||
public void Lock()
|
public void Lock()
|
||||||
{
|
{
|
||||||
IsUnlocked = false;
|
_isUnlocked = false;
|
||||||
_sessionTracker.Unregister(_sessionId);
|
_sessionTracker.Unregister(_sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +98,7 @@ public sealed class HrKpiAccessService : IHrKpiAccessService, IDisposable
|
|||||||
AccessPasswordSettingsWriter.SavePasswordHash(_environment.ContentRootPath, HrKpiAccessOptions.SectionName, passwordHash);
|
AccessPasswordSettingsWriter.SavePasswordHash(_environment.ContentRootPath, HrKpiAccessOptions.SectionName, passwordHash);
|
||||||
_options.PasswordHash = passwordHash;
|
_options.PasswordHash = passwordHash;
|
||||||
_options.Password = string.Empty;
|
_options.Password = string.Empty;
|
||||||
IsUnlocked = true;
|
_isUnlocked = true;
|
||||||
_sessionTracker.Register(_sessionId, "HR KPI", username.Trim(), GetRemoteAddress());
|
_sessionTracker.Register(_sessionId, "HR KPI", username.Trim(), GetRemoteAddress());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
dotnet build /p:HanaClientDll="D:\pfad\zu\Sap.Data.Hana.Core.v2.1.dll"
|
dotnet build /p:HanaClientDll="D:\pfad\zu\Sap.Data.Hana.Core.v2.1.dll"
|
||||||
-->
|
-->
|
||||||
<HanaClientDll Condition="'$(HanaClientDll)' == ''">C:\Program Files\sap\hdbclient\dotnetcore\v2.1\Sap.Data.Hana.Core.v2.1.dll</HanaClientDll>
|
<HanaClientDll Condition="'$(HanaClientDll)' == ''">C:\Program Files\sap\hdbclient\dotnetcore\v2.1\Sap.Data.Hana.Core.v2.1.dll</HanaClientDll>
|
||||||
|
<HanaClientNativeDll Condition="'$(HanaClientNativeDll)' == ''">C:\Program Files\sap\hdbclient\dotnetcore\libadonetHDB.dll</HanaClientNativeDll>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -50,6 +51,7 @@
|
|||||||
<Content Include="erg.png" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="Always" />
|
<Content Include="erg.png" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="Always" />
|
||||||
<Content Include="login.png" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="Always" />
|
<Content Include="login.png" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="Always" />
|
||||||
<Content Include="manometer.png" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="Always" />
|
<Content Include="manometer.png" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="Always" />
|
||||||
|
<Content Include="$(HanaClientNativeDll)" Link="libadonetHDB.dll" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" Condition="Exists('$(HanaClientNativeDll)')" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -94,5 +96,7 @@
|
|||||||
<Target Name="CheckHanaClient" BeforeTargets="ResolveAssemblyReferences">
|
<Target Name="CheckHanaClient" BeforeTargets="ResolveAssemblyReferences">
|
||||||
<Warning Condition="!Exists('$(HanaClientDll)')"
|
<Warning Condition="!Exists('$(HanaClientDll)')"
|
||||||
Text="SAP HANA Client DLL nicht gefunden: $(HanaClientDll). Bitte SAP HANA Client installieren (https://tools.hana.ondemand.com) oder MSBuild-Property 'HanaClientDll' setzen." />
|
Text="SAP HANA Client DLL nicht gefunden: $(HanaClientDll). Bitte SAP HANA Client installieren (https://tools.hana.ondemand.com) oder MSBuild-Property 'HanaClientDll' setzen." />
|
||||||
|
<Warning Condition="!Exists('$(HanaClientNativeDll)')"
|
||||||
|
Text="SAP HANA native DLL nicht gefunden: $(HanaClientNativeDll). Bitte SAP HANA Client installieren oder MSBuild-Property 'HanaClientNativeDll' setzen." />
|
||||||
</Target>
|
</Target>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -47,4 +47,4 @@
|
|||||||
"LandingPage": {
|
"LandingPage": {
|
||||||
"ShowWalkingLabFigure": false
|
"ShowWalkingLabFigure": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# Architekturreview: Static-Methoden und Hardcodings
|
|
||||||
|
|
||||||
Stand: 2026-05-15
|
|
||||||
|
|
||||||
## Ergebnis
|
|
||||||
|
|
||||||
Viele `static`-Methoden sind im aktuellen Code nicht automatisch falsch. Reine Hilfsfunktionen ohne Zustand sind als `static` fachlich und technisch akzeptabel.
|
|
||||||
|
|
||||||
Das eigentliche Architekturthema ist nicht `static` selbst, sondern dass einige grosse Klassen fachliche Regeln, Datenimport, Filterung, KPI-Berechnung und Visualisierungsvorbereitung gleichzeitig enthalten.
|
|
||||||
|
|
||||||
## Befunde
|
|
||||||
|
|
||||||
| Prioritaet | Bereich | Befund | Empfehlung |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| Medium | HR KPI | Testpersonen sind aktuell im Code ausgeschlossen. | In `appsettings.json` oder DB-Tabelle `HrKpiExclusionRules` verschieben. |
|
|
||||||
| Medium | Finance Vergleich | Vergleich ist aktuell auf Jahr `2025` und Referenztext `check.xlsx / Power BI Stand 29.04.2026` fixiert. | Jahr auswählbar machen und Referenzstand aus Daten/Konfiguration lesen. |
|
|
||||||
| Medium | Finance Reconciliation | Hauswaehrung je Land wird im Service aufgelöst. | Langfristig in Standort-/Finance-Konfiguration verschieben. |
|
|
||||||
| Low/Medium | Database Seed | Finance-Sollwerte, Budgetkurse und IC-Defaultregeln werden per Seed angelegt. | Fuer Produktion Import/Pflegeoberflaeche vorsehen. |
|
|
||||||
| Low | UI/Formatierung | Viele kleine `static`-Formatierungs- und Mappingmethoden. | Akzeptabel, solange sie klein und zustandslos bleiben. |
|
|
||||||
|
|
||||||
## Grosse Klassen
|
|
||||||
|
|
||||||
| Klasse | Umfang | Bewertung |
|
|
||||||
| --- | ---: | --- |
|
|
||||||
| `Services/HrKpi/HrKpiDashboardBuilder.cs` | ca. 1'145 Zeilen | Zu viel Verantwortung in einer Klasse. |
|
|
||||||
| `Services/ManagementCockpitService.cs` | ca. 811 Zeilen | Analyse, Import, Aggregation und Hinweise liegen stark gebündelt. |
|
|
||||||
| `Services/FinanceReconciliationService.cs` | ca. 370 Zeilen | Noch akzeptabel, aber fachliche Teilregeln koennen spaeter ausgelagert werden. |
|
|
||||||
|
|
||||||
## Was korrekt ist
|
|
||||||
|
|
||||||
Diese Arten von `static`-Methoden sind unkritisch:
|
|
||||||
|
|
||||||
- Textnormalisierung
|
|
||||||
- Datum-/Zahlenformatierung
|
|
||||||
- kleine Parser
|
|
||||||
- einfache Mappingfunktionen
|
|
||||||
- lokale UI-Formatierung
|
|
||||||
- deterministische Berechnung ohne externe Abhängigkeiten
|
|
||||||
|
|
||||||
## Was verbessert werden sollte
|
|
||||||
|
|
||||||
1. HR-Testpersonen aus dem Code in Konfiguration oder DB verschieben.
|
|
||||||
2. Finance-Vergleich von fixem Jahr `2025` auf auswählbares Jahr umstellen.
|
|
||||||
3. Referenztext und Referenzstand nicht hart im UI pflegen.
|
|
||||||
4. Hauswaehrungen je Land aus Konfiguration oder Finance-Stammdaten lesen.
|
|
||||||
5. Grosse Klassen schrittweise aufteilen:
|
|
||||||
- Reader
|
|
||||||
- Filter
|
|
||||||
- Rules
|
|
||||||
- Metrics
|
|
||||||
- VisualBuilder
|
|
||||||
|
|
||||||
## Empfehlung
|
|
||||||
|
|
||||||
Nicht alle `static`-Methoden entfernen. Das waere kein sinnvoller Refactor.
|
|
||||||
|
|
||||||
Zuerst sollten die fachlich veraenderbaren Regeln aus dem Code herausgezogen werden. Danach kann die Klassenstruktur gezielt verkleinert werden.
|
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -2216,3 +2216,56 @@ Technische Dateien:
|
|||||||
- `Services/LandingPageSettingsService.cs`
|
- `Services/LandingPageSettingsService.cs`
|
||||||
- `Services/UiTextService.cs`
|
- `Services/UiTextService.cs`
|
||||||
- `appsettings.json`
|
- `appsettings.json`
|
||||||
|
|
||||||
|
## Nachtrag 2026-05-26: Publish-Fixes, HR-KPI-Upload, Varianten und PDF
|
||||||
|
|
||||||
|
Deployment / Blazor:
|
||||||
|
|
||||||
|
- Interaktive Blazor-Server-Render-Mode-Deklarationen fuer die routbaren Seiten ergaenzt, damit Buttons und Formulare auf der publizierten IIS-Seite reagieren.
|
||||||
|
- Navigation auf relative Links umgestellt, damit die Anwendung unter `/BiDashboard` nicht auf Root-URLs ohne PathBase springt.
|
||||||
|
- Admin-, Finance- und HR-KPI-Login auf serverseitige POST-Endpunkte mit Unlock-Cookies umgestellt, damit Login auch auf der publizierten Seite ohne Blazor-Click-Event funktioniert.
|
||||||
|
- SAP-HANA Native DLL `libadonetHDB.dll` wird mitpubliziert und `HDBDOTNETCORE` im `web.config` auf den Publish-Ordner gesetzt.
|
||||||
|
|
||||||
|
HR-KPI:
|
||||||
|
|
||||||
|
- Massenupload fuer die fuenf HR-KPI-Dateien direkt im HR-KPI-Cockpit eingebaut.
|
||||||
|
- Upload-Ziel auf dem Server: `C:\inetpub\wwwcust\BiDashboard\hrdata`.
|
||||||
|
- Erwartete Dateien:
|
||||||
|
- `Saldiperstichdatum.xlsx`
|
||||||
|
- `Exportkommengehen.xlsx`
|
||||||
|
- `HR_KPI_Export.xlsx`
|
||||||
|
- `Abwesenheitinstunden.xlsx`
|
||||||
|
- `Personalausgeschieden.xlsx`
|
||||||
|
- Allgemeiner Zeitraumfilter `Von Datum` / `Bis Datum` ersetzt die reine Austrittsbeschriftung.
|
||||||
|
- Fluktuation nutzt den Zeitraum ueber `Austrittsdatum`.
|
||||||
|
- Absenzquote nutzt den Zeitraum als Nenner mit Arbeitstagen statt fix `21 Tage`.
|
||||||
|
- Wenn die Rexx-Absenzen selbst Datumsfelder enthalten, werden Absenzen auf den Zeitraum gefiltert.
|
||||||
|
- Wenn die Rexx-Absenzen keine Datumsfelder enthalten, wird angenommen, dass `Abwesenheitinstunden.xlsx` bereits fuer den gewaehlten Zeitraum exportiert wurde.
|
||||||
|
- `MudDatePicker` nutzt explizit `de-CH`, damit Eingaben wie `31.03.2026` auf Server und Browser stabil geparst werden.
|
||||||
|
- PDF-/Druckbuttons je HR-KPI-Reiter ergaenzt, z. B. fuer `Fluktuation`, `Absenzen`, `Ampel`, `Mitarbeitende` und `Datenstatus`.
|
||||||
|
- Der Druck/PDF-Export rendert nur den jeweiligen Reiterinhalt inkl. Titel, Datenordner und Filterzusammenfassung.
|
||||||
|
- Serverseitige HR-KPI-Varianten eingefuehrt:
|
||||||
|
- Speicherdatei: `C:\inetpub\wwwcust\BiDashboard\hrdata\hr-kpi-variants.json`
|
||||||
|
- letzte Selektion wird serverseitig gespeichert und beim Oeffnen wieder geladen
|
||||||
|
- Varianten sind fuer alle Benutzer sichtbar
|
||||||
|
- Varianten koennen gespeichert, geladen, aktualisiert, umbenannt und geloescht werden
|
||||||
|
- Initiale Testvarianten fuer HR wurden auf dem Server angelegt:
|
||||||
|
- `Fluktuation Q1 2026`
|
||||||
|
- `Fluktuation Jahr 2026`
|
||||||
|
- `Absenzquote Q1 2026`
|
||||||
|
|
||||||
|
Firewall / Betrieb:
|
||||||
|
|
||||||
|
- Aktueller HANA-Fehler nach DLL-Fix ist Netzwerk/Firewall, nicht mehr SAP-DLL:
|
||||||
|
- Webserver: `10.120.1.17`
|
||||||
|
- HANA Internal: `10.194.65.22:30015`
|
||||||
|
- India HANA: `20.197.20.60:30015`
|
||||||
|
- SAP OData: `10.194.64.29:8000`
|
||||||
|
- SharePoint: `trafagag.sharepoint.com:443`
|
||||||
|
- Support-Mail an externen Support vorbereitet; Freigabe muss vom Webserver zu den Zielsystemen erfolgen.
|
||||||
|
|
||||||
|
Validierung:
|
||||||
|
|
||||||
|
- `dotnet build .\TrafagSalesExporter.csproj --no-restore --verbosity minimal` erfolgreich.
|
||||||
|
- `dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --no-restore --verbosity minimal` erfolgreich mit `78/78` Tests.
|
||||||
|
- Mehrfach erfolgreich nach `\\trch-webapp-bidashboard.trafagch.local\BiDashboard$\` publiziert.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<environmentVariables>
|
<environmentVariables>
|
||||||
<environmentVariable name="ASPNETCORE_DETAILEDERRORS" value="true" />
|
<environmentVariable name="ASPNETCORE_DETAILEDERRORS" value="true" />
|
||||||
<environmentVariable name="ASPNETCORE_PATHBASE" value="/BiDashboard" />
|
<environmentVariable name="ASPNETCORE_PATHBASE" value="/BiDashboard" />
|
||||||
|
<environmentVariable name="HDBDOTNETCORE" value="C:\inetpub\wwwcust\BiDashboard" />
|
||||||
</environmentVariables>
|
</environmentVariables>
|
||||||
</aspNetCore>
|
</aspNetCore>
|
||||||
</system.webServer>
|
</system.webServer>
|
||||||
|
|||||||
@@ -9,5 +9,57 @@ window.trafagDownload = {
|
|||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
},
|
||||||
|
printElement: function (elementId) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (!element) {
|
||||||
|
window.print();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = element.querySelector(".hr-print-header h1")?.textContent || document.title;
|
||||||
|
const printWindow = window.open("", "_blank", "noopener,noreferrer,width=1200,height=900");
|
||||||
|
if (!printWindow) {
|
||||||
|
window.print();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = Array.from(document.querySelectorAll('link[rel="stylesheet"], style'))
|
||||||
|
.map(node => node.outerHTML)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
printWindow.document.open();
|
||||||
|
printWindow.document.write(`<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>${title}</title>
|
||||||
|
${styles}
|
||||||
|
<style>
|
||||||
|
@page { size: A4 landscape; margin: 10mm; }
|
||||||
|
body { background: #fff !important; color: #111 !important; }
|
||||||
|
.hr-print-toolbar, .mud-table-pagination, .mud-tabs-toolbar { display: none !important; }
|
||||||
|
.mud-paper { box-shadow: none !important; border: 1px solid #ddd !important; break-inside: avoid; page-break-inside: avoid; }
|
||||||
|
.mud-table-container { overflow: visible !important; }
|
||||||
|
.mud-table-root { width: 100% !important; }
|
||||||
|
.hr-print-section { display: block !important; }
|
||||||
|
.hr-print-header { display: block !important; margin-bottom: 14px; }
|
||||||
|
.hr-print-header h1 { margin: 0 0 4px 0; font-size: 22px; }
|
||||||
|
.hr-print-header p { margin: 0 0 3px 0; color: #555; font-size: 11px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${element.outerHTML}
|
||||||
|
<script>
|
||||||
|
window.onload = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.print();
|
||||||
|
window.close();
|
||||||
|
}, 250);
|
||||||
|
};
|
||||||
|
<\/script>
|
||||||
|
</body>
|
||||||
|
</html>`);
|
||||||
|
printWindow.document.close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user