Add published HR KPI workflow fixes

This commit is contained in:
2026-05-26 13:23:03 +02:00
parent 5f3c3497b8
commit d853f53df8
44 changed files with 14990 additions and 122 deletions
+15
View File
@@ -1,8 +1,23 @@
# Build artifacts
bin/
obj/
verify_probe_out*/
build_verify/
output/
# Visual Studio user/IDE files
.vs/
*.user
*.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 ISnackbar Snackbar
@inject IUiTextService UiText
@inject ILogger<AdminAccessPanel> Logger
@inject NavigationManager Navigation
<MudPaper Class="pa-4 mb-4" Elevation="1" Style="max-width:520px;">
<MudStack Spacing="3">
@@ -14,12 +16,20 @@
@T("Admin-Zugang ist noch nicht konfiguriert.", "Admin access is not configured yet.")
</MudAlert>
}
<MudTextField @bind-Value="_username" Label="@T("Name", "Name")" Disabled="@(!AdminAccess.IsConfigured)" />
<MudTextField @bind-Value="_password" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!AdminAccess.IsConfigured)" />
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Unlock"
StartIcon="@Icons.Material.Filled.LockOpen" Disabled="@(!AdminAccess.IsConfigured)">
@T("Admin entsperren", "Unlock admin")
</MudButton>
<form method="post" action="@AccessUrl">
<input type="hidden" name="returnUrl" value="@Navigation.Uri" />
<MudStack Spacing="3">
<MudTextField T="string" Name="username" Label="@T("Name", "Name")" Disabled="@(!AdminAccess.IsConfigured)" />
<MudTextField T="string" Name="password" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!AdminAccess.IsConfigured)" />
<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 />
<MudExpansionPanels Elevation="0">
<MudExpansionPanel Text="@T("Passwort ändern", "Change password")" Icon="@Icons.Material.Filled.Password">
@@ -45,9 +55,19 @@
private string? _currentPassword;
private string? _newPassword;
private string? _newPasswordRepeat;
private int _unlockClickCount;
private string AccessUrl => new Uri(new Uri(Navigation.BaseUri), "access/admin").ToString();
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))
{
Snackbar.Add(T("Admin-Anmeldung fehlgeschlagen.", "Admin sign-in failed."), Severity.Error);
+8 -1
View File
@@ -12,7 +12,14 @@
</head>
<body>
<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="js/download.js"></script>
</body>
@@ -1,8 +1,10 @@
@using TrafagSalesExporter.Services
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@inject IFinanceCockpitAccessService FinanceAccess
@inject ISnackbar Snackbar
@inject NavigationManager Navigation
@inject IUiTextService UiText
@inject ILogger<FinanceCockpitUnlockPanel> Logger
<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.")
</MudAlert>
}
<MudTextField @bind-Value="_username" Label="@T("Name", "Name")" Disabled="@(!FinanceAccess.IsConfigured)" />
<MudTextField @bind-Value="_password" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!FinanceAccess.IsConfigured)" />
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="UnlockAsync"
StartIcon="@Icons.Material.Filled.LockOpen" Disabled="@(!FinanceAccess.IsConfigured)">
@T("Finance Cockpit entsperren", "Unlock Finance Cockpit")
</MudButton>
<form method="post" action="@AccessUrl">
<input type="hidden" name="returnUrl" value="@Navigation.Uri" />
<MudStack Spacing="3">
<MudTextField T="string" Name="username" Label="@T("Name", "Name")" Disabled="@(!FinanceAccess.IsConfigured)" />
<MudTextField T="string" Name="password" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!FinanceAccess.IsConfigured)" />
<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 />
<MudExpansionPanels Elevation="0">
<MudExpansionPanel Text="@T("Passwort ändern", "Change password")" Icon="@Icons.Material.Filled.Password">
@@ -48,9 +58,19 @@
private string? _currentPassword;
private string? _newPassword;
private string? _newPasswordRepeat;
private int _unlockClickCount;
private string AccessUrl => new Uri(new Uri(Navigation.BaseUri), "access/finance").ToString();
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))
{
Snackbar.Add(T("Finance-Cockpit-Anmeldung fehlgeschlagen.", "Finance Cockpit sign-in failed."), Severity.Error);
@@ -2,9 +2,13 @@
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject IUiTextService UiText
@inject IJSRuntime JsRuntime
<MudTabs Elevation="1" Rounded="false" PanelClass="pt-4">
<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)
<MudGrid Class="mt-4">
@@ -24,9 +28,13 @@
@CriticalBalancesTable(Result.CriticalTimeBalances)
</MudItem>
</MudGrid>
</div>
</MudTabPanel>
<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)
<MudGrid Class="mt-4">
@@ -67,14 +75,22 @@
@MonthlyBars(Result.TurnoverVisuals)
</MudItem>
</MudGrid>
</div>
</MudTabPanel>
<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)
@MetricGrid(Result.PeriodComparisonMetrics)
</div>
</MudTabPanel>
<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)
<MudGrid Class="mt-4">
<MudItem xs="12" md="6">
@@ -110,9 +126,13 @@
</PagerContent>
</MudTable>
</MudPaper>
</div>
</MudTabPanel>
<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)
<MudGrid Class="mt-4">
@@ -145,19 +165,28 @@
</MudPaper>
</MudItem>
</MudGrid>
</div>
</MudTabPanel>
<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)
</div>
</MudTabPanel>
<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)
<MudGrid Class="mt-4">
<MudItem xs="12">
@DataQualityTable(Result.DataQualityIssues)
</MudItem>
</MudGrid>
</div>
</MudTabPanel>
<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 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)
=> severity == "Warning" ? Color.Warning : Color.Default;
@@ -629,6 +694,14 @@
min-height: 100%;
}
.hr-print-header {
display: none;
}
.hr-print-toolbar {
width: 100%;
}
.hr-guide-steps {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr));
@@ -7,42 +7,42 @@
<MudNavMenu>
<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")
</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")
</MudNavLink>
@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")
</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")
</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")
</MudNavLink>
<MudNavGroup Title="@T("Admin", "Admin")" Icon="@Icons.Material.Filled.AdminPanelSettings">
<AuthorizeView Policy="@SecurityPolicies.AdminOnly">
<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")
</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")
</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")
</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")
</MudNavLink>
</Authorized>
</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")
</MudNavLink>
</MudNavGroup>
@@ -55,14 +55,14 @@
}
</MudNavGroup>
<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")
</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")
</MudNavLink>
</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")
</MudNavLink>
</MudNavMenu>
@@ -78,7 +78,7 @@
private void LockFinanceCockpit()
{
FinanceAccess.Lock();
Navigation.NavigateTo("/");
Navigation.NavigateTo(string.Empty);
}
private void HandleLanguageChanged()
@@ -1,4 +1,5 @@
@page "/admin/sessions"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
@using TrafagSalesExporter.Services
@inject IAccessSessionTracker SessionTracker
@@ -1,4 +1,5 @@
@page "/"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@using TrafagSalesExporter.Services
@inject IUiTextService UiText
@inject ILandingPageSettingsService LandingSettings
@@ -1,4 +1,5 @@
@page "/export-dashboard"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@using System.Diagnostics
@using TrafagSalesExporter.Services
@inject IDashboardPageService DashboardPageActions
@@ -1,4 +1,5 @@
@page "/finance-cockpit/vergleich"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject IFinanceReconciliationService FinanceReconciliationService
@@ -1,4 +1,5 @@
@page "/finance-rules"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
@using System.Reflection
@using TrafagSalesExporter.Models
@@ -1,4 +1,5 @@
@page "/finance-cockpit/schulung"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@inject TrafagSalesExporter.Services.IUiTextService UiText
<PageTitle>@T("Finance Schulung", "Finance training")</PageTitle>
+372 -12
View File
@@ -1,13 +1,16 @@
@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.Services
@inject IHrKpiService HrKpiService
@inject IOptions<HrKpiDataSourceOptions> DataSourceOptions
@inject IHrKpiAccessService HrKpiAccess
@inject ISnackbar Snackbar
@inject IUiTextService UiText
@inject IJSRuntime JsRuntime
@inject NavigationManager Navigation
@inject IWebHostEnvironment Environment
<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.")
</MudAlert>
}
<MudTextField @bind-Value="_hrUsername" Label="@T("Name", "Name")" Disabled="@(!HrKpiAccess.IsConfigured)" />
<MudTextField @bind-Value="_hrPassword" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!HrKpiAccess.IsConfigured)" />
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="UnlockHrKpiAsync"
StartIcon="@Icons.Material.Filled.LockOpen" Disabled="@(!HrKpiAccess.IsConfigured)">
@T("HR KPI entsperren", "Unlock HR KPI")
</MudButton>
<form method="post" action="@AccessUrl">
<input type="hidden" name="returnUrl" value="@Navigation.Uri" />
<MudStack Spacing="3">
<MudTextField T="string" Name="username" Label="@T("Name", "Name")" Disabled="@(!HrKpiAccess.IsConfigured)" />
<MudTextField T="string" Name="password" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!HrKpiAccess.IsConfigured)" />
<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 />
<MudExpansionPanels Elevation="0">
<MudExpansionPanel Text="@T("Passwort ändern", "Change password")" Icon="@Icons.Material.Filled.Password">
@@ -57,7 +68,24 @@ else
<MudItem xs="12" md="5">
<MudTextField @bind-Value="_dataFolder"
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 xs="6" md="2">
<MudSelect T="int?" @bind-Value="_year" Label="@T("Austrittsjahr", "Leaver year")" Dense Clearable>
@@ -86,10 +114,10 @@ else
Label="@T("Managementsicht", "Management view")" />
</MudItem>
<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 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 xs="12" md="2">
<MudSelect T="int?" @bind-Value="_entryYear" Label="@T("Eintrittsjahr", "Entry year")" Dense Clearable>
@@ -154,6 +182,50 @@ else
@T("Drucken/PDF", "Print/PDF")
</MudButton>
</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>
</MudPaper>
}
@@ -194,8 +266,26 @@ else
private string? _currentPassword;
private string? _newPassword;
private string? _newPasswordRepeat;
private int _unlockClickCount;
private string AccessUrl => new Uri(new Uri(Navigation.BaseUri), "access/hr").ToString();
private bool _loading;
private bool _uploading;
private string _serverUploadFolder = string.Empty;
private HrKpiResult? _result;
private string _selectionStorePath = string.Empty;
private HrKpiSelectionStore _selectionStore = new();
private string? _variantName;
private string? _selectedVariantName;
private List<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 =
[
("Alle", "Alle"),
@@ -205,10 +295,20 @@ else
];
private readonly List<string> _ampelOptions = ["Gruen", "Gelb", "Rot"];
private readonly List<string> _restferienOptions = ["Gruen", "Rot"];
private readonly CultureInfo _dateCulture = CultureInfo.GetCultureInfo("de-CH");
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)
{
await LoadAsync();
@@ -241,6 +341,8 @@ else
SearchText = _searchText,
ManagementView = _managementView
});
_selectionStore.LastSelection = CreateSelectionState();
await WriteSelectionStoreAsync();
}
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()
{
_unlockClickCount++;
if (!HrKpiAccess.TryUnlock(_hrUsername ?? string.Empty, _hrPassword ?? string.Empty))
{
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 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"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@inject TrafagSalesExporter.Services.IUiTextService UiText
<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"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@using TrafagSalesExporter.Services
@inject ILogsPageService LogsPageActions
@inject ISnackbar Snackbar
@@ -1,4 +1,5 @@
@page "/management-cockpit"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject IManagementCockpitPageService CockpitPageService
@@ -1,4 +1,5 @@
@page "/manual-imports"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@@ -1,4 +1,5 @@
@page "/settings"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@@ -1,4 +1,5 @@
@page "/source-viewer"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.WebUtilities
@inject IWebHostEnvironment Environment
@@ -1,4 +1,5 @@
@page "/standorte"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
@using Microsoft.AspNetCore.Components.Forms
@using System.Text.Json
@@ -1,4 +1,5 @@
@page "/transformations"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
@using System.Reflection
@using TrafagSalesExporter.Models
@@ -184,6 +184,8 @@ public sealed class HrAbsenceRow
public string Organisationseinheit { get; set; } = string.Empty;
public string Stelle { 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 KrankheitLangStd { get; set; }
public decimal KrankheitGesamtStd { get; set; }
+68
View File
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Server.IISIntegration;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using MudBlazor.Services;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
@@ -139,7 +140,74 @@ app.UseAuthentication();
app.UseAuthorization();
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>()
.AddInteractiveServerRenderMode();
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 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;
_environment = environment;
_logger = logger;
_httpContextAccessor = httpContextAccessor;
}
public bool IsEnabled => _options.Enabled;
@@ -33,13 +41,21 @@ public sealed class AdminAccessService : IAdminAccessService
!string.IsNullOrWhiteSpace(_options.Username) &&
(!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)
{
if (!IsEnabled)
{
IsUnlocked = true;
_isUnlocked = true;
_logger.LogInformation("Admin access unlocked because AdminAccess is disabled.");
return true;
}
@@ -48,6 +64,12 @@ public sealed class AdminAccessService : IAdminAccessService
string.IsNullOrEmpty(password) ||
!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;
}
@@ -55,7 +77,14 @@ public sealed class AdminAccessService : IAdminAccessService
? VerifyPasswordHash(password, _options.PasswordHash)
: 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;
}
@@ -74,11 +103,11 @@ public sealed class AdminAccessService : IAdminAccessService
AccessPasswordSettingsWriter.SavePasswordHash(_environment.ContentRootPath, AdminAccessOptions.SectionName, passwordHash);
_options.PasswordHash = passwordHash;
_options.Password = string.Empty;
IsUnlocked = true;
_isUnlocked = true;
return true;
}
public void Lock() => IsUnlocked = false;
public void Lock() => _isUnlocked = false;
private static bool VerifyPasswordHash(string password, string configuredHash)
{
@@ -21,18 +21,21 @@ public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService,
private readonly IHostEnvironment _environment;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IAccessSessionTracker _sessionTracker;
private readonly ILogger<FinanceCockpitAccessService> _logger;
private readonly string _sessionId = Guid.NewGuid().ToString("N");
public FinanceCockpitAccessService(
IOptions<FinanceCockpitAccessOptions> options,
IHostEnvironment environment,
IHttpContextAccessor httpContextAccessor,
IAccessSessionTracker sessionTracker)
IAccessSessionTracker sessionTracker,
ILogger<FinanceCockpitAccessService> logger)
{
_options = options.Value;
_environment = environment;
_httpContextAccessor = httpContextAccessor;
_sessionTracker = sessionTracker;
_logger = logger;
}
public bool IsEnabled => _options.Enabled;
@@ -42,13 +45,21 @@ public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService,
!string.IsNullOrWhiteSpace(_options.Username) &&
(!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)
{
if (!IsEnabled)
{
IsUnlocked = true;
_isUnlocked = true;
_logger.LogInformation("Finance Cockpit access unlocked because FinanceCockpitAccess is disabled.");
return true;
}
@@ -57,6 +68,12 @@ public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService,
string.IsNullOrEmpty(password) ||
!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;
}
@@ -64,7 +81,14 @@ public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService,
? VerifyPasswordHash(password, _options.PasswordHash)
: 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)
_sessionTracker.Register(_sessionId, "Finance Cockpit", username.Trim(), GetRemoteAddress());
return valid;
@@ -72,7 +96,7 @@ public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService,
public void Lock()
{
IsUnlocked = false;
_isUnlocked = false;
_sessionTracker.Unregister(_sessionId);
}
@@ -91,7 +115,7 @@ public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService,
AccessPasswordSettingsWriter.SavePasswordHash(_environment.ContentRootPath, FinanceCockpitAccessOptions.SectionName, passwordHash);
_options.PasswordHash = passwordHash;
_options.Password = string.Empty;
IsUnlocked = true;
_isUnlocked = true;
_sessionTracker.Register(_sessionId, "Finance Cockpit", username.Trim(), GetRemoteAddress());
return true;
}
@@ -90,6 +90,7 @@ internal sealed class HrKpiDashboardBuilder
var turnoverEmployees = ApplyTurnoverEmployeeFilters(employees, normalizedOptions).ToList();
var turnoverHeadcountLeavers = ApplyTurnoverHeadcountLeaverFilters(leavers, normalizedOptions).ToList();
var analysisPeriod = ResolveAnalysisPeriod(normalizedOptions);
var filteredEmployees = ApplyEmployeeFilters(employees, normalizedOptions).ToList();
var filteredEmployeeNumbers = filteredEmployees
.Where(x => x.Personalnummer.HasValue)
@@ -97,6 +98,7 @@ internal sealed class HrKpiDashboardBuilder
.ToHashSet();
employees = filteredEmployees;
var absenceRowsWithoutDates = absences.Count(x => !x.VonDatum.HasValue && !x.BisDatum.HasValue);
absences = ApplyAbsenceFilters(absences, normalizedOptions, filteredEmployeeNumbers).ToList();
leavers = ApplyLeaverFilters(leavers, normalizedOptions).ToList();
var turnoverPeriod = ResolveTurnoverPeriodScope(normalizedOptions, leavers);
@@ -104,9 +106,9 @@ internal sealed class HrKpiDashboardBuilder
result.Employees = employees;
result.Absences = absences;
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.AbsenceMetrics = BuildAbsenceMetrics(employees, absences);
result.AbsenceMetrics = BuildAbsenceMetrics(employees, absences, analysisPeriod);
result.TimeVacationMetrics = BuildTimeVacationMetrics(employees);
result.PeriodComparisonMetrics = BuildPeriodComparisonMetrics(turnoverEmployees, turnoverHeadcountLeavers, leavers, turnoverPeriod);
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.");
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.");
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))
result.Notices.Add($"Hauptdatei fehlt: {_dataSources.MainFile}. Ohne diese Datei sind keine HR-KPIs moeglich.");
if (!context.HasFile(_dataSources.SapFile))
@@ -299,6 +303,8 @@ internal sealed class HrKpiDashboardBuilder
{
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 lang = ReadDecimal(row, headers, "Krank nicht buchbar angetreten (Stunden Ind.)", "Krankheit_Lang_Std");
var gesamt = kurz + lang;
@@ -310,6 +316,8 @@ internal sealed class HrKpiDashboardBuilder
Organisationseinheit = ReadString(row, headers, "Organisation"),
Stelle = ReadString(row, headers, "Stelle"),
Status = ReadString(row, headers, "Personal Status", "Status"),
VonDatum = fromDate,
BisDatum = toDate ?? fromDate,
KrankheitKurzStd = kurz,
KrankheitLangStd = lang,
KrankheitGesamtStd = gesamt,
@@ -406,6 +414,7 @@ internal sealed class HrKpiDashboardBuilder
=> rows.Where(x => MatchesFilter(x.Organisationseinheit, options.Organisationseinheit) &&
x.Personalnummer.HasValue &&
filteredEmployeeNumbers.Contains(x.Personalnummer.Value) &&
MatchesAbsencePeriodFilter(x, options) &&
MatchesTextSearch(options.SearchText, x.Name, x.Personalnummer?.ToString(CultureInfo.InvariantCulture) ?? string.Empty));
private static IEnumerable<HrLeaverRow> ApplyLeaverFilters(IEnumerable<HrLeaverRow> rows, HrKpiOptions options)
@@ -429,7 +438,8 @@ internal sealed class HrKpiDashboardBuilder
IReadOnlyCollection<HrKpiEmployeeRow> turnoverEmployees,
IReadOnlyCollection<HrLeaverRow> turnoverHeadcountLeavers,
IReadOnlyCollection<HrLeaverRow> leavers,
TurnoverPeriodScope period)
TurnoverPeriodScope period,
AnalysisPeriod analysisPeriod)
{
var activeCount = CountDistinctPersons(employees.Select(x => x.Personalnummer));
var activeFixedCount = CountDistinctPersons(employees
@@ -439,7 +449,8 @@ internal sealed class HrKpiDashboardBuilder
var turnoverDenominator = ResolveTurnoverDenominator(turnoverEmployees, turnoverIntervals, period);
var fte = employees.Sum(x => x.Fte);
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 employeeLeavers = CountDistinctPersons(leavers.Where(x => x.IstArbeitnehmerkuendigung).Select(x => x.Personalnummer));
var turnover = turnoverDenominator == 0 ? 0 : relevantLeavers / turnoverDenominator;
@@ -558,13 +569,15 @@ internal sealed class HrKpiDashboardBuilder
private static List<HrKpiMetric> BuildAbsenceMetrics(
IReadOnlyCollection<HrKpiEmployeeRow> employees,
IReadOnlyCollection<HrAbsenceRow> absences)
IReadOnlyCollection<HrAbsenceRow> absences,
AnalysisPeriod analysisPeriod)
{
var totalSick = absences.Sum(x => x.KrankheitstageGesamt);
var shortSick = absences.Sum(x => x.KrankheitstageKurz);
var longSick = absences.Sum(x => x.KrankheitstageLang);
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 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 = "Krankheit Kurz", Value = shortSick.ToString("N1"), Detail = "Rexx kurz / 8.4h", Severity = "Normal" },
new() { Label = "Krankheit Lang", Value = longSick.ToString("N1"), Detail = "Rexx lang / 8.4h", Severity = longSick > shortSick ? "Warning" : "Normal" },
new() { Label = "Krankenquote", Value = absenceRate.ToString("P1"), Detail = "Krankheitstage / (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 = "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" }
@@ -1052,6 +1065,23 @@ internal sealed class HrKpiDashboardBuilder
(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)
{
var period = ResolveEmploymentPeriod(options);
@@ -1078,6 +1108,34 @@ internal sealed class HrKpiDashboardBuilder
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)
=> personalNumbers
.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 AnalysisPeriod(DateTime? Start, DateTime? End, decimal Workdays, string Label, bool HasPeriod);
private sealed record TurnoverEmploymentInterval(int Personalnummer, DateTime? Eintrittsdatum, DateTime? Austrittsdatum);
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.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)
{
if (!IsEnabled)
{
IsUnlocked = true;
_isUnlocked = true;
return true;
}
@@ -64,7 +71,7 @@ public sealed class HrKpiAccessService : IHrKpiAccessService, IDisposable
? VerifyPasswordHash(password, _options.PasswordHash)
: FixedEquals(password, _options.Password);
IsUnlocked = valid;
_isUnlocked = valid;
if (valid)
_sessionTracker.Register(_sessionId, "HR KPI", username.Trim(), GetRemoteAddress());
return valid;
@@ -72,7 +79,7 @@ public sealed class HrKpiAccessService : IHrKpiAccessService, IDisposable
public void Lock()
{
IsUnlocked = false;
_isUnlocked = false;
_sessionTracker.Unregister(_sessionId);
}
@@ -91,7 +98,7 @@ public sealed class HrKpiAccessService : IHrKpiAccessService, IDisposable
AccessPasswordSettingsWriter.SavePasswordHash(_environment.ContentRootPath, HrKpiAccessOptions.SectionName, passwordHash);
_options.PasswordHash = passwordHash;
_options.Password = string.Empty;
IsUnlocked = true;
_isUnlocked = true;
_sessionTracker.Register(_sessionId, "HR KPI", username.Trim(), GetRemoteAddress());
return true;
}
@@ -15,6 +15,7 @@
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>
<HanaClientNativeDll Condition="'$(HanaClientNativeDll)' == ''">C:\Program Files\sap\hdbclient\dotnetcore\libadonetHDB.dll</HanaClientNativeDll>
</PropertyGroup>
<ItemGroup>
@@ -50,6 +51,7 @@
<Content Include="erg.png" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="Always" />
<Content Include="login.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>
@@ -94,5 +96,7 @@
<Target Name="CheckHanaClient" BeforeTargets="ResolveAssemblyReferences">
<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." />
<Warning Condition="!Exists('$(HanaClientNativeDll)')"
Text="SAP HANA native DLL nicht gefunden: $(HanaClientNativeDll). Bitte SAP HANA Client installieren oder MSBuild-Property 'HanaClientNativeDll' setzen." />
</Target>
</Project>
+1 -1
View File
@@ -47,4 +47,4 @@
"LandingPage": {
"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.
File diff suppressed because it is too large Load Diff
Binary file not shown.
+53
View File
@@ -2216,3 +2216,56 @@ Technische Dateien:
- `Services/LandingPageSettingsService.cs`
- `Services/UiTextService.cs`
- `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.
+1
View File
@@ -14,6 +14,7 @@
<environmentVariables>
<environmentVariable name="ASPNETCORE_DETAILEDERRORS" value="true" />
<environmentVariable name="ASPNETCORE_PATHBASE" value="/BiDashboard" />
<environmentVariable name="HDBDOTNETCORE" value="C:\inetpub\wwwcust\BiDashboard" />
</environmentVariables>
</aspNetCore>
</system.webServer>
@@ -9,5 +9,57 @@ window.trafagDownload = {
link.click();
document.body.removeChild(link);
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();
}
};