Refine cockpit navigation and HR access

This commit is contained in:
2026-05-15 11:14:46 +02:00
parent e20693243d
commit 83e556e89e
13 changed files with 556 additions and 198 deletions
@@ -2,35 +2,38 @@
@inject TrafagSalesExporter.Services.IUiTextService UiText @inject TrafagSalesExporter.Services.IUiTextService UiText
<MudNavMenu> <MudNavMenu>
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard"> <MudNavGroup Title="@T("Finance Cockpit", "Finance Cockpit")" Icon="@Icons.Material.Filled.Analytics" Expanded="true">
@T("Dashboard", "Dashboard") <MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">
</MudNavLink> @T("Export Dashboard", "Export dashboard")
<AuthorizeView Policy="@SecurityPolicies.AdminOnly"> </MudNavLink>
<Authorized> <MudNavLink Href="/management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.QueryStats">
<MudNavLink Href="/standorte" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.LocationOn"> @T("Management Analyse", "Management analysis")
@T("Standorte", "Sites") </MudNavLink>
</MudNavLink> <MudNavLink Href="/finance-cockpit/vergleich" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.CompareArrows">
<MudNavLink Href="/transformations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Transform"> @T("Soll/Ist Vergleich", "Actual/reference comparison")
@T("Transformationen", "Transformations") </MudNavLink>
</MudNavLink> </MudNavGroup>
</Authorized>
</AuthorizeView>
<MudNavLink Href="/management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Analytics">
@T("Management Cockpit", "Management Cockpit")
</MudNavLink>
<MudNavLink Href="/hr-kpi" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Groups"> <MudNavLink Href="/hr-kpi" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Groups">
@T("HR KPI", "HR KPI") @T("HR KPI (Login)", "HR KPI (login)")
</MudNavLink>
<AuthorizeView Policy="@SecurityPolicies.AdminOnly">
<Authorized>
<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">
@T("Logs", "Logs")
</MudNavLink> </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">
@T("Standorte", "Sites")
</MudNavLink>
<MudNavLink Href="/transformations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Transform">
@T("Transformationen", "Transformations")
</MudNavLink>
<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">
@T("Logs", "Logs")
</MudNavLink>
</MudNavGroup>
</MudNavMenu> </MudNavMenu>
@code { @code {
@@ -8,66 +8,9 @@
@inject IUiTextService UiText @inject IUiTextService UiText
@implements IDisposable @implements IDisposable
<PageTitle>@T("Dashboard", "Dashboard")</PageTitle> <PageTitle>@T("Export Dashboard", "Export dashboard")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("Dashboard", "Dashboard")</MudText> <MudText Typo="Typo.h4" Class="mb-4">@T("Export Dashboard", "Export dashboard")</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudStack Row AlignItems="AlignItems.Center" Class="mb-3">
<MudText Typo="Typo.h6">@T("Net Sales Actuals 2025 Referenz", "Net sales actuals 2025 reference")</MudText>
<MudSpacer />
<MudText Typo="Typo.caption">check.xlsx / Power BI Stand 29.04.2026</MudText>
</MudStack>
<MudTable Items="_netSalesReferenceRows" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Firma", "Company")</MudTh>
<MudTh>@T("Ist 2025", "Actual 2025")</MudTh>
<MudTh>@T("IC-Abzug", "IC deduction")</MudTh>
<MudTh>@T("Ist exkl. IC", "Actual excl. IC")</MudTh>
<MudTh>@T("Referenz", "Reference")</MudTh>
<MudTh>@T("Summenfeld", "Value field")</MudTh>
<MudTh>@T("Quelle", "Source")</MudTh>
<MudTh>@T("Differenz", "Difference")</MudTh>
<MudTh>@T("Diff exkl. IC", "Diff excl. IC")</MudTh>
<MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>@T("Zeilen", "Rows")</MudTh>
<MudTh>@T("Status", "Status")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd>@FormatAmount(context.ActualValue)</MudTd>
<MudTd>@FormatAmount(context.IntercompanyDeduction)</MudTd>
<MudTd>@FormatAmount(context.ActualValueExcludingIntercompany)</MudTd>
<MudTd>@FormatAmount(context.ReferenceValue)</MudTd>
<MudTd>@(string.IsNullOrWhiteSpace(context.ValueField) ? "-" : context.ValueField)</MudTd>
<MudTd>@context.ReferenceSource</MudTd>
<MudTd>@FormatAmount(context.Difference)</MudTd>
<MudTd>@FormatAmount(context.DifferenceExcludingIntercompany)</MudTd>
<MudTd>@(string.IsNullOrWhiteSpace(context.Currencies) ? "-" : context.Currencies)</MudTd>
<MudTd>@(context.RowCount > 0 ? context.RowCount.ToString("N0") : "-")</MudTd>
<MudTd>
@if (context.Status == "OK")
{
<MudChip T="string" Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">OK</MudChip>
}
else if (context.Status == "Pruefen")
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning" Variant="Variant.Filled">@T("Pruefen", "Check")</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined">@T("Keine Daten", "No data")</MudChip>
}
</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.caption">@T("Keine Referenzdaten fuer aktive Standorte gefunden.", "No reference data found for active sites.")</MudText>
</NoRecordsContent>
</MudTable>
<MudAlert Severity="Severity.Info" Dense Variant="Variant.Outlined" Class="mt-3">
@T("Vergleich: Jahr 2025 aus Invoice Date, sonst Extraction Date. Das Summenfeld wird automatisch aus Sales Price/Value, DocTotalFC - VatSumFC oder DocTotal - VatSum gewaehlt; Belegkopfwerte werden pro DocEntry nur einmal gezaehlt. IC-Abzug ist eine Diagnose fuer den aktuellen Trafag-IT-Abgleich und veraendert die Originaldaten nicht.", "Comparison: year 2025 from Invoice Date, otherwise Extraction Date. The value field is selected automatically from Sales Price/Value, DocTotalFC - VatSumFC, or DocTotal - VatSum; document header values are counted only once per DocEntry. IC deduction is a diagnostic value for the current Trafag IT reconciliation and does not change the original data.")
</MudAlert>
</MudPaper>
<MudPaper Class="pa-4 mb-4" Elevation="1"> <MudPaper Class="pa-4 mb-4" Elevation="1">
<MudStack Row AlignItems="AlignItems.Center" Spacing="4"> <MudStack Row AlignItems="AlignItems.Center" Spacing="4">
@@ -212,7 +155,6 @@
@code { @code {
private List<DashboardRow> _dashboardRows = new(); private List<DashboardRow> _dashboardRows = new();
private List<ConsolidatedDashboardRow> _consolidatedRows = new(); private List<ConsolidatedDashboardRow> _consolidatedRows = new();
private List<NetSalesReferenceRow> _netSalesReferenceRows = new();
private bool _loading = true; private bool _loading = true;
private bool _anyRunning; private bool _anyRunning;
private CancellationTokenSource? _pollingCts; private CancellationTokenSource? _pollingCts;
@@ -229,7 +171,6 @@
var state = await DashboardPageActions.LoadAsync(); var state = await DashboardPageActions.LoadAsync();
_dashboardRows = state.DashboardRows; _dashboardRows = state.DashboardRows;
_consolidatedRows = state.ConsolidatedRows; _consolidatedRows = state.ConsolidatedRows;
_netSalesReferenceRows = state.NetSalesReferenceRows;
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting(); _anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting();
_loading = false; _loading = false;
@@ -460,9 +401,6 @@
return Task.CompletedTask; return Task.CompletedTask;
} }
private static string FormatAmount(decimal? value)
=> value.HasValue ? value.Value.ToString("N2") : "-";
private static string FormatException(Exception ex) private static string FormatException(Exception ex)
=> ex.InnerException is null ? ex.Message : $"{ex.Message} Inner: {ex.InnerException.Message}"; => ex.InnerException is null ? ex.Message : $"{ex.Message} Inner: {ex.InnerException.Message}";
@@ -0,0 +1,96 @@
@page "/finance-cockpit/vergleich"
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject IFinanceReconciliationService FinanceReconciliationService
@inject IUiTextService UiText
<PageTitle>@T("Soll/Ist Vergleich", "Actual/reference comparison")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("Soll/Ist Vergleich", "Actual/reference comparison")</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudStack Row AlignItems="AlignItems.Center" Class="mb-3">
<div>
<MudText Typo="Typo.h6">@T("Net Sales Actuals 2025 Referenz", "Net sales actuals 2025 reference")</MudText>
<MudText Typo="Typo.caption">check.xlsx / Power BI Stand 29.04.2026</MudText>
</div>
<MudSpacer />
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Refresh"
OnClick="LoadAsync" Disabled="_loading">
@(_loading ? T("Lade...", "Loading...") : T("Aktualisieren", "Refresh"))
</MudButton>
</MudStack>
<MudTable Items="_netSalesReferenceRows" Dense Hover Striped Loading="_loading">
<HeaderContent>
<MudTh>@T("Firma", "Company")</MudTh>
<MudTh>@T("Ist 2025", "Actual 2025")</MudTh>
<MudTh>@T("IC-Abzug", "IC deduction")</MudTh>
<MudTh>@T("Ist exkl. IC", "Actual excl. IC")</MudTh>
<MudTh>@T("Referenz", "Reference")</MudTh>
<MudTh>@T("Summenfeld", "Value field")</MudTh>
<MudTh>@T("Quelle", "Source")</MudTh>
<MudTh>@T("Differenz", "Difference")</MudTh>
<MudTh>@T("Diff exkl. IC", "Diff excl. IC")</MudTh>
<MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>@T("Zeilen", "Rows")</MudTh>
<MudTh>@T("Status", "Status")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd>@FormatAmount(context.ActualValue)</MudTd>
<MudTd>@FormatAmount(context.IntercompanyDeduction)</MudTd>
<MudTd>@FormatAmount(context.ActualValueExcludingIntercompany)</MudTd>
<MudTd>@FormatAmount(context.ReferenceValue)</MudTd>
<MudTd>@(string.IsNullOrWhiteSpace(context.ValueField) ? "-" : context.ValueField)</MudTd>
<MudTd>@context.ReferenceSource</MudTd>
<MudTd>@FormatAmount(context.Difference)</MudTd>
<MudTd>@FormatAmount(context.DifferenceExcludingIntercompany)</MudTd>
<MudTd>@(string.IsNullOrWhiteSpace(context.Currencies) ? "-" : context.Currencies)</MudTd>
<MudTd>@(context.RowCount > 0 ? context.RowCount.ToString("N0") : "-")</MudTd>
<MudTd>
@if (context.Status == "OK")
{
<MudChip T="string" Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">OK</MudChip>
}
else if (context.Status == "Pruefen")
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning" Variant="Variant.Filled">@T("Pruefen", "Check")</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined">@T("Keine Daten", "No data")</MudChip>
}
</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.caption">@T("Keine Referenzdaten fuer aktive Standorte gefunden.", "No reference data found for active sites.")</MudText>
</NoRecordsContent>
</MudTable>
<MudAlert Severity="Severity.Info" Dense Variant="Variant.Outlined" Class="mt-3">
@T("Vergleich: Jahr 2025 aus Buchungsdatum, sonst Invoice Date, sonst Extraction Date. Das Summenfeld wird automatisch aus Sales Price/Value, DocTotalFC - VatSumFC oder DocTotal - VatSum gewaehlt; Belegkopfwerte werden pro DocEntry nur einmal gezaehlt. IC-Abzug ist eine Diagnose fuer den aktuellen Abgleich und veraendert die Originaldaten nicht.", "Comparison: year 2025 from posting date, otherwise invoice date, otherwise extraction date. The value field is selected automatically from Sales Price/Value, DocTotalFC - VatSumFC, or DocTotal - VatSum; document header values are counted only once per DocEntry. IC deduction is a diagnostic value for the current reconciliation and does not change the original data.")
</MudAlert>
</MudPaper>
@code {
private List<NetSalesReferenceRow> _netSalesReferenceRows = new();
private bool _loading = true;
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
_loading = true;
_netSalesReferenceRows = await FinanceReconciliationService.BuildNetSalesReferenceRowsAsync(2025);
_loading = false;
}
private static string FormatAmount(decimal? value)
=> value.HasValue ? value.Value.ToString("N2") : "-";
private string T(string german, string english) => UiText.Text(german, english);
}
+150 -87
View File
@@ -4,6 +4,7 @@
@using TrafagSalesExporter.Services @using TrafagSalesExporter.Services
@inject IHrKpiService HrKpiService @inject IHrKpiService HrKpiService
@inject IOptions<HrKpiDataSourceOptions> DataSourceOptions @inject IOptions<HrKpiDataSourceOptions> DataSourceOptions
@inject IHrKpiAccessService HrKpiAccess
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject IUiTextService UiText @inject IUiTextService UiText
@@ -11,94 +12,125 @@
<MudText Typo="Typo.h4" Class="mb-4">@T("HR KPI", "HR KPI")</MudText> <MudText Typo="Typo.h4" Class="mb-4">@T("HR KPI", "HR KPI")</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1"> @if (!CanShowHrKpi)
<MudGrid> {
<MudItem xs="12" md="5"> <MudPaper Class="pa-4 mb-4" Elevation="1" Style="max-width:520px;">
<MudTextField @bind-Value="_dataFolder" Label="@T("Datenordner", "Data folder")" /> <MudStack Spacing="3">
</MudItem> <MudAlert Severity="Severity.Warning" Variant="Variant.Outlined">
<MudItem xs="6" md="2"> @T("HR KPI enthaelt sensible Personaldaten. Bitte separat anmelden.", "HR KPI contains sensitive HR data. Please sign in separately.")
<MudSelect T="int?" @bind-Value="_year" Label="@T("Austrittsjahr", "Leaver year")" Dense Clearable> </MudAlert>
@foreach (var option in _result?.ExitYearOptions ?? []) @if (!HrKpiAccess.IsConfigured)
{ {
<MudSelectItem Value="@((int?)option)">@option</MudSelectItem> <MudAlert Severity="Severity.Error" Variant="Variant.Filled">
} @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.")
</MudSelect> </MudAlert>
</MudItem> }
<MudItem xs="12" md="3"> <MudTextField @bind-Value="_hrUsername" Label="@T("Name", "Name")" Disabled="@(!HrKpiAccess.IsConfigured)" />
<MudSelect T="string" @bind-Value="_organisation" Label="@T("Organisation", "Organisation")" Dense Clearable> <MudTextField @bind-Value="_hrPassword" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!HrKpiAccess.IsConfigured)" />
@foreach (var option in _result?.OrganisationOptions ?? []) <MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="UnlockHrKpiAsync"
{ StartIcon="@Icons.Material.Filled.LockOpen" Disabled="@(!HrKpiAccess.IsConfigured)">
<MudSelectItem Value="@option">@option</MudSelectItem> @T("HR KPI entsperren", "Unlock HR KPI")
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="LoadAsync"
StartIcon="@Icons.Material.Filled.Refresh" Disabled="_loading" FullWidth>
@(_loading ? T("Lade...", "Loading...") : T("Laden", "Load"))
</MudButton> </MudButton>
</MudItem> </MudStack>
<MudItem xs="12" md="3"> </MudPaper>
<MudDatePicker @bind-Date="_fromDate" Label="@T("Von Austritt", "Exit from")" Clearable DateFormat="dd.MM.yyyy" /> }
</MudItem> else
<MudItem xs="12" md="3"> {
<MudDatePicker @bind-Date="_toDate" Label="@T("Bis Austritt", "Exit to")" Clearable DateFormat="dd.MM.yyyy" /> <MudPaper Class="pa-4 mb-4" Elevation="1">
</MudItem> <MudGrid>
<MudItem xs="12" md="2"> <MudItem xs="12" md="5">
<MudSelect T="int?" @bind-Value="_entryYear" Label="@T("Eintrittsjahr", "Entry year")" Dense Clearable> <MudTextField @bind-Value="_dataFolder" Label="@T("Datenordner", "Data folder")" />
@foreach (var option in _result?.EntryYearOptions ?? []) </MudItem>
{ <MudItem xs="6" md="2">
<MudSelectItem Value="@((int?)option)">@option</MudSelectItem> <MudSelect T="int?" @bind-Value="_year" Label="@T("Austrittsjahr", "Leaver year")" Dense Clearable>
} @foreach (var option in _result?.ExitYearOptions ?? [])
</MudSelect> {
</MudItem> <MudSelectItem Value="@((int?)option)">@option</MudSelectItem>
<MudItem xs="12" md="4"> }
<MudTextField @bind-Value="_searchText" Label="@T("Suche Name / Personalnr.", "Search name / personnel no.")" /> </MudSelect>
</MudItem> </MudItem>
<MudItem xs="12" md="3"> <MudItem xs="12" md="3">
<MudSelect T="string" @bind-Value="_kostenstelle" Label="@T("Kostenstelle", "Cost center")" Dense Clearable> <MudSelect T="string" @bind-Value="_organisation" Label="@T("Organisation", "Organisation")" Dense Clearable>
@foreach (var option in _result?.KostenstelleOptions ?? []) @foreach (var option in _result?.OrganisationOptions ?? [])
{ {
<MudSelectItem Value="@option">@option</MudSelectItem> <MudSelectItem Value="@option">@option</MudSelectItem>
} }
</MudSelect> </MudSelect>
</MudItem> </MudItem>
<MudItem xs="12" md="3"> <MudItem xs="12" md="2">
<MudSelect T="string" @bind-Value="_mitarbeitertyp" Label="@T("Mitarbeitertyp", "Employee type")" Dense Clearable> <MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="LoadAsync"
@foreach (var option in _result?.MitarbeitertypOptions ?? []) StartIcon="@Icons.Material.Filled.Refresh" Disabled="_loading" FullWidth>
{ @(_loading ? T("Lade...", "Loading...") : T("Laden", "Load"))
<MudSelectItem Value="@option">@option</MudSelectItem> </MudButton>
} </MudItem>
</MudSelect> <MudItem xs="12" md="3">
</MudItem> <MudDatePicker @bind-Date="_fromDate" Label="@T("Von Austritt", "Exit from")" Clearable DateFormat="dd.MM.yyyy" />
<MudItem xs="12" md="2"> </MudItem>
<MudSelect T="string" @bind-Value="_fluktuationFilter" Label="@T("Fluktuation", "Turnover")" Dense> <MudItem xs="12" md="3">
@foreach (var option in _fluktuationOptions) <MudDatePicker @bind-Date="_toDate" Label="@T("Bis Austritt", "Exit to")" Clearable DateFormat="dd.MM.yyyy" />
{ </MudItem>
<MudSelectItem Value="@option.Key">@option.Label</MudSelectItem> <MudItem xs="12" md="2">
} <MudSelect T="int?" @bind-Value="_entryYear" Label="@T("Eintrittsjahr", "Entry year")" Dense Clearable>
</MudSelect> @foreach (var option in _result?.EntryYearOptions ?? [])
</MudItem> {
<MudItem xs="6" md="2"> <MudSelectItem Value="@((int?)option)">@option</MudSelectItem>
<MudSelect T="string" @bind-Value="_glzAmpel" Label="@T("GLZ", "Time")" Dense Clearable> }
@foreach (var option in _ampelOptions) </MudSelect>
{ </MudItem>
<MudSelectItem Value="@option">@option</MudSelectItem> <MudItem xs="12" md="4">
} <MudTextField @bind-Value="_searchText" Label="@T("Suche Name / Personalnr.", "Search name / personnel no.")" />
</MudSelect> </MudItem>
</MudItem> <MudItem xs="12" md="3">
<MudItem xs="6" md="2"> <MudSelect T="string" @bind-Value="_kostenstelle" Label="@T("Kostenstelle", "Cost center")" Dense Clearable>
<MudSelect T="string" @bind-Value="_restferienAmpel" Label="@T("Restferien", "Vacation")" Dense Clearable> @foreach (var option in _result?.KostenstelleOptions ?? [])
@foreach (var option in _restferienOptions) {
{ <MudSelectItem Value="@option">@option</MudSelectItem>
<MudSelectItem Value="@option">@option</MudSelectItem> }
} </MudSelect>
</MudSelect> </MudItem>
</MudItem> <MudItem xs="12" md="3">
</MudGrid> <MudSelect T="string" @bind-Value="_mitarbeitertyp" Label="@T("Mitarbeitertyp", "Employee type")" Dense Clearable>
</MudPaper> @foreach (var option in _result?.MitarbeitertypOptions ?? [])
{
<MudSelectItem Value="@option">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="2">
<MudSelect T="string" @bind-Value="_fluktuationFilter" Label="@T("Fluktuation", "Turnover")" Dense>
@foreach (var option in _fluktuationOptions)
{
<MudSelectItem Value="@option.Key">@option.Label</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="6" md="2">
<MudSelect T="string" @bind-Value="_glzAmpel" Label="@T("GLZ", "Time")" Dense Clearable>
@foreach (var option in _ampelOptions)
{
<MudSelectItem Value="@option">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="6" md="2">
<MudSelect T="string" @bind-Value="_restferienAmpel" Label="@T("Restferien", "Vacation")" Dense Clearable>
@foreach (var option in _restferienOptions)
{
<MudSelectItem Value="@option">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="2">
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" OnClick="LockHrKpi"
StartIcon="@Icons.Material.Filled.Lock" FullWidth>
@T("Sperren", "Lock")
</MudButton>
</MudItem>
</MudGrid>
</MudPaper>
}
@if (_result is not null) @if (CanShowHrKpi && _result is not null)
{ {
@if (_result.Notices.Count > 0) @if (_result.Notices.Count > 0)
{ {
@@ -127,6 +159,8 @@
private string? _glzAmpel; private string? _glzAmpel;
private string? _restferienAmpel; private string? _restferienAmpel;
private string? _searchText; private string? _searchText;
private string? _hrUsername;
private string? _hrPassword;
private bool _loading; private bool _loading;
private HrKpiResult? _result; private HrKpiResult? _result;
private readonly List<(string Key, string Label)> _fluktuationOptions = private readonly List<(string Key, string Label)> _fluktuationOptions =
@@ -142,11 +176,19 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
_dataFolder = DataSourceOptions.Value.Normalize().DataFolder; _dataFolder = DataSourceOptions.Value.Normalize().DataFolder;
await LoadAsync(); if (CanShowHrKpi)
{
await LoadAsync();
}
} }
private async Task LoadAsync() private async Task LoadAsync()
{ {
if (!CanShowHrKpi)
{
return;
}
_loading = true; _loading = true;
try try
{ {
@@ -176,5 +218,26 @@
} }
} }
private async Task UnlockHrKpiAsync()
{
if (!HrKpiAccess.TryUnlock(_hrUsername ?? string.Empty, _hrPassword ?? string.Empty))
{
Snackbar.Add(T("HR-KPI-Anmeldung fehlgeschlagen.", "HR KPI sign-in failed."), Severity.Error);
return;
}
_hrPassword = string.Empty;
await LoadAsync();
}
private void LockHrKpi()
{
HrKpiAccess.Lock();
_result = null;
_hrPassword = string.Empty;
}
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);
} }
@@ -5,9 +5,9 @@
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject IUiTextService UiText @inject IUiTextService UiText
<PageTitle>@T("Management Cockpit", "Management Cockpit")</PageTitle> <PageTitle>@T("Management Analyse", "Management analysis")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("Management Cockpit", "Management Cockpit")</MudText> <MudText Typo="Typo.h4" Class="mb-4">@T("Management Analyse", "Management analysis")</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1"> <MudPaper Class="pa-4 mb-4" Elevation="1">
<MudGrid> <MudGrid>
@@ -64,6 +64,12 @@
} }
</MudSelect> </MudSelect>
</MudItem> </MudItem>
<MudItem xs="12" md="2">
<MudTextField @bind-Value="_centralLandFilter" Label="@T("Landfilter", "Country filter")" />
</MudItem>
<MudItem xs="12" md="2">
<MudTextField @bind-Value="_centralTscFilter" Label="TSC" />
</MudItem>
<MudItem xs="12" md="2"> <MudItem xs="12" md="2">
<MudSelect T="int?" @bind-Value="_selectedCentralMonth" Label='@T("Monat (optional)", "Month (optional)")' Dense Clearable> <MudSelect T="int?" @bind-Value="_selectedCentralMonth" Label='@T("Monat (optional)", "Month (optional)")' Dense Clearable>
@foreach (var month in Enumerable.Range(1, 12)) @foreach (var month in Enumerable.Range(1, 12))
@@ -102,10 +108,22 @@
</MudSelect> </MudSelect>
</MudItem> </MudItem>
<MudItem xs="12"> <MudItem xs="12">
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="AnalyzeCentral" <MudStack Row Spacing="2" AlignItems="AlignItems.Center">
StartIcon="@Icons.Material.Filled.QueryStats" Disabled="_analyzingCentral || _selectedCentralYear == 0"> <MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="AnalyzeCentral"
@(_analyzingCentral ? T("Analysiere...", "Analyzing...") : T("Zentrale Auswertung laden", "Load central analysis")) StartIcon="@Icons.Material.Filled.QueryStats" Disabled="_analyzingCentral || _selectedCentralYear == 0">
</MudButton> @(_analyzingCentral ? T("Analysiere...", "Analyzing...") : T("Zentrale Auswertung laden", "Load central analysis"))
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Default" OnClick="ClearCentralScope"
StartIcon="@Icons.Material.Filled.FilterAltOff">
@T("Global", "Global")
</MudButton>
@if (!string.IsNullOrWhiteSpace(_centralLandFilter) || !string.IsNullOrWhiteSpace(_centralTscFilter))
{
<MudChip T="string" Size="Size.Small" Color="Color.Info" Variant="Variant.Outlined">
@T("Gefiltert", "Filtered"): @($"{(_centralLandFilter ?? "-")} / {(_centralTscFilter ?? "-")}")
</MudChip>
}
</MudStack>
</MudItem> </MudItem>
</MudGrid> </MudGrid>
</MudPaper> </MudPaper>
@@ -332,6 +350,8 @@
private ManagementCockpitCentralResult? _centralResult; private ManagementCockpitCentralResult? _centralResult;
private int _selectedCentralYear; private int _selectedCentralYear;
private int? _selectedCentralMonth; private int? _selectedCentralMonth;
private string? _centralLandFilter;
private string? _centralTscFilter;
private string _selectedFileValueField = ManagementCockpitValueFieldKeys.SalesPriceValue; private string _selectedFileValueField = ManagementCockpitValueFieldKeys.SalesPriceValue;
private string _selectedCentralValueField = ManagementCockpitValueFieldKeys.SalesPriceValue; private string _selectedCentralValueField = ManagementCockpitValueFieldKeys.SalesPriceValue;
private IEnumerable<string> _selectedCentralAdditionalValueFields = []; private IEnumerable<string> _selectedCentralAdditionalValueFields = [];
@@ -385,6 +405,8 @@
ValueField = _selectedFileValueField, ValueField = _selectedFileValueField,
TargetCurrency = _selectedFileTargetCurrency TargetCurrency = _selectedFileTargetCurrency
}); });
_centralLandFilter = _result.Summary.Land;
_centralTscFilter = _result.Summary.Tsc;
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -408,7 +430,9 @@
{ {
ValueField = _selectedCentralValueField, ValueField = _selectedCentralValueField,
AdditionalValueFields = _selectedCentralAdditionalValueFields.ToList(), AdditionalValueFields = _selectedCentralAdditionalValueFields.ToList(),
TargetCurrency = _selectedCentralTargetCurrency TargetCurrency = _selectedCentralTargetCurrency,
LandFilter = _centralLandFilter,
TscFilter = _centralTscFilter
}); });
} }
catch (Exception ex) catch (Exception ex)
@@ -421,6 +445,12 @@
} }
} }
private void ClearCentralScope()
{
_centralLandFilter = null;
_centralTscFilter = null;
}
private static Severity MapSeverity(string severity) => severity switch private static Severity MapSeverity(string severity) => severity switch
{ {
"Warning" => Severity.Warning, "Warning" => Severity.Warning,
@@ -34,6 +34,8 @@ public class ManagementCockpitAnalysisOptions
public string ValueField { get; set; } = ManagementCockpitValueFieldKeys.SalesPriceValue; public string ValueField { get; set; } = ManagementCockpitValueFieldKeys.SalesPriceValue;
public List<string> AdditionalValueFields { get; set; } = []; public List<string> AdditionalValueFields { get; set; } = [];
public string TargetCurrency { get; set; } = ManagementCockpitCurrencyOptions.Native; public string TargetCurrency { get; set; } = ManagementCockpitCurrencyOptions.Native;
public string? LandFilter { get; set; }
public string? TscFilter { get; set; }
} }
public class ManagementCockpitSummary public class ManagementCockpitSummary
@@ -89,6 +91,8 @@ public class ManagementCockpitCentralFilter
public int? Month { get; set; } public int? Month { get; set; }
public string ValueField { get; set; } = ManagementCockpitValueFieldKeys.SalesPriceValue; public string ValueField { get; set; } = ManagementCockpitValueFieldKeys.SalesPriceValue;
public string TargetCurrency { get; set; } = ManagementCockpitCurrencyOptions.Native; public string TargetCurrency { get; set; } = ManagementCockpitCurrencyOptions.Native;
public string? Land { get; set; }
public string? Tsc { get; set; }
} }
public class ManagementCockpitCentralSummary public class ManagementCockpitCentralSummary
+2
View File
@@ -45,6 +45,7 @@ builder.Services.AddAuthorization(options =>
builder.Services.AddMudServices(); builder.Services.AddMudServices();
builder.Services.AddHttpClient(nameof(ExchangeRateImportService)); builder.Services.AddHttpClient(nameof(ExchangeRateImportService));
builder.Services.Configure<HrKpiDataSourceOptions>(builder.Configuration.GetSection(HrKpiDataSourceOptions.SectionName)); builder.Services.Configure<HrKpiDataSourceOptions>(builder.Configuration.GetSection(HrKpiDataSourceOptions.SectionName));
builder.Services.Configure<HrKpiAccessOptions>(builder.Configuration.GetSection(HrKpiAccessOptions.SectionName));
builder.Services.AddDbContextFactory<AppDbContext>(options => builder.Services.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlite("Data Source=trafag_exporter.db;Default Timeout=60")); options.UseSqlite("Data Source=trafag_exporter.db;Default Timeout=60"));
@@ -104,6 +105,7 @@ builder.Services.AddScoped<IManagementCockpitPageService, ManagementCockpitPageS
builder.Services.AddScoped<IDashboardPageService, DashboardPageService>(); builder.Services.AddScoped<IDashboardPageService, DashboardPageService>();
builder.Services.AddScoped<ILogsPageService, LogsPageService>(); builder.Services.AddScoped<ILogsPageService, LogsPageService>();
builder.Services.AddScoped<ITransformationsPageService, TransformationsPageService>(); builder.Services.AddScoped<ITransformationsPageService, TransformationsPageService>();
builder.Services.AddScoped<IHrKpiAccessService, HrKpiAccessService>();
var app = builder.Build(); var app = builder.Build();
@@ -0,0 +1,11 @@
namespace TrafagSalesExporter.Security;
public sealed class HrKpiAccessOptions
{
public const string SectionName = "HrKpiAccess";
public bool Enabled { get; set; } = true;
public string Username { get; set; } = "hr";
public string PasswordHash { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
@@ -12,14 +12,10 @@ public interface IDashboardPageService
public sealed class DashboardPageService : IDashboardPageService public sealed class DashboardPageService : IDashboardPageService
{ {
private readonly IDbContextFactory<AppDbContext> _dbFactory; private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly IFinanceReconciliationService _financeReconciliationService;
public DashboardPageService( public DashboardPageService(IDbContextFactory<AppDbContext> dbFactory)
IDbContextFactory<AppDbContext> dbFactory,
IFinanceReconciliationService financeReconciliationService)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
_financeReconciliationService = financeReconciliationService;
} }
public async Task<DashboardPageState> LoadAsync() public async Task<DashboardPageState> LoadAsync()
@@ -69,8 +65,7 @@ public sealed class DashboardPageService : IDashboardPageService
return new DashboardPageState return new DashboardPageState
{ {
DashboardRows = rows, DashboardRows = rows,
ConsolidatedRows = BuildConsolidatedRows(await db.ExportSettings.FirstOrDefaultAsync() ?? new()), ConsolidatedRows = BuildConsolidatedRows(await db.ExportSettings.FirstOrDefaultAsync() ?? new())
NetSalesReferenceRows = await _financeReconciliationService.BuildNetSalesReferenceRowsAsync(2025)
}; };
} }
@@ -119,7 +114,6 @@ public sealed class DashboardPageState
{ {
public List<DashboardRow> DashboardRows { get; set; } = []; public List<DashboardRow> DashboardRows { get; set; } = [];
public List<ConsolidatedDashboardRow> ConsolidatedRows { get; set; } = []; public List<ConsolidatedDashboardRow> ConsolidatedRows { get; set; } = [];
public List<NetSalesReferenceRow> NetSalesReferenceRows { get; set; } = [];
} }
public sealed class DashboardRow public sealed class DashboardRow
@@ -0,0 +1,74 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
using TrafagSalesExporter.Security;
namespace TrafagSalesExporter.Services;
public interface IHrKpiAccessService
{
bool IsEnabled { get; }
bool IsConfigured { get; }
bool IsUnlocked { get; }
bool TryUnlock(string username, string password);
void Lock();
}
public sealed class HrKpiAccessService : IHrKpiAccessService
{
private readonly HrKpiAccessOptions _options;
public HrKpiAccessService(IOptions<HrKpiAccessOptions> options)
{
_options = options.Value;
}
public bool IsEnabled => _options.Enabled;
public bool IsConfigured =>
!IsEnabled ||
!string.IsNullOrWhiteSpace(_options.Username) &&
(!string.IsNullOrWhiteSpace(_options.PasswordHash) || !string.IsNullOrEmpty(_options.Password));
public bool IsUnlocked { get; private set; }
public bool TryUnlock(string username, string password)
{
if (!IsEnabled)
{
IsUnlocked = true;
return true;
}
if (!IsConfigured ||
string.IsNullOrWhiteSpace(username) ||
string.IsNullOrEmpty(password) ||
!FixedEquals(username.Trim(), _options.Username.Trim()))
{
return false;
}
var valid = !string.IsNullOrWhiteSpace(_options.PasswordHash)
? VerifyPasswordHash(password, _options.PasswordHash)
: FixedEquals(password, _options.Password);
IsUnlocked = valid;
return valid;
}
public void Lock() => IsUnlocked = false;
private static bool VerifyPasswordHash(string password, string configuredHash)
{
var passwordHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(password)));
return FixedEquals(passwordHash, configuredHash.Trim());
}
private static bool FixedEquals(string left, string right)
{
var leftBytes = Encoding.UTF8.GetBytes(left);
var rightBytes = Encoding.UTF8.GetBytes(right);
return leftBytes.Length == rightBytes.Length &&
CryptographicOperations.FixedTimeEquals(leftBytes, rightBytes);
}
}
@@ -199,14 +199,17 @@ public class ManagementCockpitService : IManagementCockpitService
.Select(row => BuildCentralAggregationRow(row, aggregation)) .Select(row => BuildCentralAggregationRow(row, aggregation))
.ToList(); .ToList();
var selectedRows = aggregatedRows var scopedRows = ApplyCentralDimensionFilters(aggregatedRows, options)
.ToList();
var selectedRows = scopedRows
.Where(r => r.PeriodDate.Year == year && (!month.HasValue || r.PeriodDate.Month == month.Value)) .Where(r => r.PeriodDate.Year == year && (!month.HasValue || r.PeriodDate.Month == month.Value))
.ToList(); .ToList();
if (selectedRows.Count == 0) if (selectedRows.Count == 0)
throw new InvalidOperationException("Für den gewählten Zeitraum gibt es keine Datensätze in der zentralen Tabelle."); throw new InvalidOperationException("Für den gewählten Zeitraum gibt es keine Datensätze in der zentralen Tabelle.");
var yearlyRows = aggregatedRows; var yearlyRows = scopedRows;
var dailyBaseRows = selectedRows var dailyBaseRows = selectedRows
.Where(r => month.HasValue) .Where(r => month.HasValue)
@@ -219,7 +222,9 @@ public class ManagementCockpitService : IManagementCockpitService
Year = year, Year = year,
Month = month, Month = month,
ValueField = aggregation.ValueField.Key, ValueField = aggregation.ValueField.Key,
TargetCurrency = aggregation.TargetCurrency TargetCurrency = aggregation.TargetCurrency,
Land = NormalizeOptionalFilter(options?.LandFilter),
Tsc = NormalizeOptionalFilter(options?.TscFilter)
}, },
Summary = new ManagementCockpitCentralSummary Summary = new ManagementCockpitCentralSummary
{ {
@@ -239,7 +244,7 @@ public class ManagementCockpitService : IManagementCockpitService
AdditionalValueFields = aggregation.AdditionalValueFields AdditionalValueFields = aggregation.AdditionalValueFields
.Select(ToValueFieldOption) .Select(ToValueFieldOption)
.ToList(), .ToList(),
Notices = BuildCentralNotices(aggregation, selectedRows.Count(x => x.MissingExchangeRate)), Notices = BuildCentralNotices(aggregation, selectedRows.Count(x => x.MissingExchangeRate), options),
YearlyTotals = yearlyRows YearlyTotals = yearlyRows
.GroupBy(x => new { x.PeriodDate.Year, x.DisplayCurrency }) .GroupBy(x => new { x.PeriodDate.Year, x.DisplayCurrency })
.OrderBy(g => g.Key.Year) .OrderBy(g => g.Key.Year)
@@ -291,6 +296,18 @@ public class ManagementCockpitService : IManagementCockpitService
}; };
} }
private static IEnumerable<CentralAggregationRow> ApplyCentralDimensionFilters(
IEnumerable<CentralAggregationRow> rows,
ManagementCockpitAnalysisOptions? options)
{
var landFilter = NormalizeOptionalFilter(options?.LandFilter);
var tscFilter = NormalizeOptionalFilter(options?.TscFilter);
return rows.Where(row =>
(landFilter is null || string.Equals(row.Land, landFilter, StringComparison.OrdinalIgnoreCase)) &&
(tscFilter is null || string.Equals(row.Tsc, tscFilter, StringComparison.OrdinalIgnoreCase)));
}
private static IEnumerable<string> GetCandidateDirectories(ExportSettings settings) private static IEnumerable<string> GetCandidateDirectories(ExportSettings settings)
{ {
yield return Path.Combine(AppContext.BaseDirectory, "output"); yield return Path.Combine(AppContext.BaseDirectory, "output");
@@ -456,7 +473,10 @@ public class ManagementCockpitService : IManagementCockpitService
}; };
} }
private static List<string> BuildCentralNotices(AggregationSelection aggregation, int missingExchangeRateCount) private static List<string> BuildCentralNotices(
AggregationSelection aggregation,
int missingExchangeRateCount,
ManagementCockpitAnalysisOptions? options)
{ {
var notices = new List<string> var notices = new List<string>
{ {
@@ -467,6 +487,13 @@ public class ManagementCockpitService : IManagementCockpitService
"Periodenlogik basiert auf Invoice Date, falls vorhanden, sonst auf Extraction Date." "Periodenlogik basiert auf Invoice Date, falls vorhanden, sonst auf Extraction Date."
}; };
var landFilter = NormalizeOptionalFilter(options?.LandFilter);
var tscFilter = NormalizeOptionalFilter(options?.TscFilter);
if (landFilter is not null || tscFilter is not null)
{
notices.Add($"Filter aus Auswahl: Land {(landFilter ?? "alle")}, TSC {(tscFilter ?? "alle")}.");
}
if (aggregation.AdditionalValueFields.Count > 0) if (aggregation.AdditionalValueFields.Count > 0)
notices.Add($"Weitere Summenfelder: {string.Join(", ", aggregation.AdditionalValueFields.Select(x => x.Label))}."); notices.Add($"Weitere Summenfelder: {string.Join(", ", aggregation.AdditionalValueFields.Select(x => x.Label))}.");
@@ -488,6 +515,9 @@ public class ManagementCockpitService : IManagementCockpitService
return notices; return notices;
} }
private static string? NormalizeOptionalFilter(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static ManagementCockpitTimeValueRow BuildTimeValueRow( private static ManagementCockpitTimeValueRow BuildTimeValueRow(
IEnumerable<CentralAggregationRow> groupRows, IEnumerable<CentralAggregationRow> groupRows,
AggregationSelection aggregation, AggregationSelection aggregation,
+5
View File
@@ -24,5 +24,10 @@
"SapFile": "HR_KPI_Export.xlsx", "SapFile": "HR_KPI_Export.xlsx",
"AbsenceFile": "Abwesenheitinstunden.xlsx", "AbsenceFile": "Abwesenheitinstunden.xlsx",
"LeaverFile": "Personalausgeschieden.xlsx" "LeaverFile": "Personalausgeschieden.xlsx"
},
"HrKpiAccess": {
"Enabled": true,
"Username": "hr",
"PasswordHash": "A8AF253007750E0C2986CBD0BC570530B4AE2417AAC59067591E708547834AE4"
} }
} }
+108
View File
@@ -1313,3 +1313,111 @@ Inhalt:
- Todo-Liste fuer Group Sales Reporting Intranet-Dashboard. - Todo-Liste fuer Group Sales Reporting Intranet-Dashboard.
- Priorisierte Punkte fuer CFO-Dokument, offene Laenderabweichungen, Intercompany, Budgetkurse und Berechtigungskonzept. - Priorisierte Punkte fuer CFO-Dokument, offene Laenderabweichungen, Intercompany, Budgetkurse und Berechtigungskonzept.
## Navigation und HR-KPI-Zugriff 2026-05-15
Geaendert:
- Linke Navigation reduziert:
- Hauptgruppe `Finance Cockpit`
- eigener Hauptpunkt `HR KPI (Login)`
- Bisherige Finance-Seiten liegen als Unterpunkte unter `Finance Cockpit`:
- Dashboard
- Management Cockpit
- Standorte
- Transformationen
- Settings
- Logs
- HR KPI hat eine separate zweite Zugriffssperre mit Name und Passwort.
- HR-Daten werden erst geladen und angezeigt, wenn die HR-KPI-Sperre erfolgreich entsperrt wurde.
Konfiguration:
- Abschnitt `HrKpiAccess` in `appsettings.json`
- Benutzer: `hr`
- Passwortvorschlag: `Trafag-HR-KPI-2026!`
- Im Repo ist nur der SHA-256-Hash gespeichert, nicht das Klartextpasswort.
Verifikation:
```text
dotnet build .\TrafagSalesExporter.csproj --no-restore -p:UseAppHost=false -p:OutDir=.\obj\verify_hrlogin\ --verbosity minimal
```
Ergebnis:
- Build erfolgreich.
- 3 bestehende MudBlazor-Analyzer-Warnungen in `Logs.razor`, `Transformations.razor` und `Standorte.razor`.
## Navigation in Finance/HR/Admin gegliedert 2026-05-15
Geaendert:
- Linke Navigation neu gegliedert:
- `Finance Cockpit`
- `HR KPI (Login)`
- `Admin`
- Unter `Finance Cockpit` stehen:
- `Export Dashboard`
- `Management Analyse`
- `Soll/Ist Vergleich`
- Unter `Admin` stehen:
- `Standorte`
- `Transformationen`
- `Settings`
- `Logs`
- Seitentitel wurden an die neuen Menuebezeichnungen angepasst.
Verifikation:
```text
dotnet build .\TrafagSalesExporter.csproj --no-restore -p:UseAppHost=false -p:OutDir=.\obj\verify_nav_groups\ --verbosity minimal
```
Ergebnis:
- Build erfolgreich.
- 3 bestehende MudBlazor-Analyzer-Warnungen in `Logs.razor`, `Transformations.razor` und `Standorte.razor`.
## Management Cockpit zentrale Filterkopplung 2026-05-15
Geaendert:
- Die untere `Zentrale Roh-Auswertung` im Management Cockpit ist nicht mehr nur global.
- Neue Filterfelder: `Landfilter` und `TSC`.
- Wenn oben eine Einzeldatei analysiert wird, uebernimmt die zentrale Auswertung automatisch Land und TSC aus dieser Datei.
- Beispiel: Auswahl `USA | TRUS | Sales_TRUS_2026-05-08.xlsx` setzt unten automatisch `USA / TRUS`.
- Button `Global` leert die Filter, falls wieder alle Laender/Standorte ausgewertet werden sollen.
- Jahres-, Monats-, Jahreswerte-, Monatswerte-, Tageswerte-, Quellen- und Laendertabellen verwenden denselben Land/TSC-Filter.
Verifikation:
```text
dotnet build .\TrafagSalesExporter.csproj --no-restore -p:UseAppHost=false -p:OutDir=.\obj\verify_management_scope2\ --verbosity minimal
```
Ergebnis:
- Build erfolgreich.
- 3 bestehende MudBlazor-Analyzer-Warnungen in `Logs.razor`, `Transformations.razor` und `Standorte.razor`.
## Finance Vergleich als eigener Reiter 2026-05-15
Geaendert:
- `Net Sales Actuals 2025 Referenz` aus dem Start-Dashboard entfernt.
- Neue Seite `Finance Vergleich` unter `Finance Cockpit` angelegt.
- Route: `/finance-cockpit/vergleich`
- Die Seite zeigt den Soll/Ist-Vergleich gegen `check.xlsx` separat, inklusive IC-Abzug, Referenzwert, Summenfeld, Differenz, Waehrung, Zeilen und Status.
- `DashboardPageService` laedt die Finance-Referenzdaten nicht mehr automatisch mit dem operativen Dashboard.
Verifikation:
```text
dotnet build .\TrafagSalesExporter.csproj --no-restore -p:UseAppHost=false -p:OutDir=.\obj\verify_finance_compare_tab\ --verbosity minimal
```
Ergebnis:
- Build erfolgreich.
- 3 bestehende MudBlazor-Analyzer-Warnungen in `Logs.razor`, `Transformations.razor` und `Standorte.razor`.