Add configurable menu structure and purchasing area

This commit is contained in:
2026-06-05 07:03:08 +02:00
parent bed1f5f0ba
commit 6f094fcac6
15 changed files with 710 additions and 101 deletions
@@ -1,127 +1,94 @@
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Security @using TrafagSalesExporter.Security
@using TrafagSalesExporter.Services
@implements IDisposable @implements IDisposable
@inject TrafagSalesExporter.Services.IUiTextService UiText @inject IUiTextService UiText
@inject TrafagSalesExporter.Services.IFinanceCockpitAccessService FinanceAccess @inject IFinanceCockpitAccessService FinanceAccess
@inject INavigationMenuService NavigationMenuService
@inject IConfiguration Configuration @inject IConfiguration Configuration
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject IAuthorizationService AuthorizationService
<MudNavMenu> <MudNavMenu>
<MudNavGroup Title="@T("Finance Cockpit", "Finance Cockpit")" Icon="@Icons.Material.Filled.Analytics" Expanded="true"> @foreach (var item in RootItems)
<MudNavLink Href="export-dashboard" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Dashboard">
@T("Export Dashboard", "Export dashboard")
</MudNavLink>
<MudNavGroup Title="@T("Management Analyse", "Management analysis")" Icon="@Icons.Material.Filled.QueryStats">
<MudNavLink Href="management-cockpit" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Speed">
@T("Schnelluebersicht", "Quick overview")
</MudNavLink>
<MudNavGroup Title="@T("Experten", "Experts")" Icon="@Icons.Material.Filled.Tune">
<MudNavLink Href="management-cockpit?section=summary" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">
@T("Finance Summary", "Finance summary")
</MudNavLink>
<MudNavLink Href="management-cockpit?section=countries" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Public">
@T("Laender Diagnose", "Country diagnostics")
</MudNavLink>
<MudNavLink Href="management-cockpit?section=status" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.FactCheck">
@T("Datenstatus", "Data status")
</MudNavLink>
<MudNavLink Href="management-cockpit?section=deviations" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.WarningAmber">
@T("Abweichungen", "Deviations")
</MudNavLink>
<MudNavLink Href="management-cockpit?section=credits" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.AssignmentReturn">
@T("Gutschriften", "Credit notes")
</MudNavLink>
<MudNavLink Href="management-cockpit?section=quality" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Rule">
@T("Datenqualitaet", "Data quality")
</MudNavLink>
<MudNavLink Href="management-cockpit?section=division&division=finance" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.PieChart">
@T("Sparten-Finanzanalyse", "Division finance")
</MudNavLink>
<MudNavLink Href="management-cockpit?section=division&division=central" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.AccountTree">
@T("Zentrale Spartenzuordnung", "Central division mapping")
</MudNavLink>
<MudNavLink Href="management-cockpit?section=3d" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.ViewInAr">
@T("3D Datenanalyse", "3D data analysis")
</MudNavLink>
<MudNavLink Href="management-cockpit?section=raw" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.QueryStats">
@T("Rohdaten Diagnose", "Raw-data diagnostics")
</MudNavLink>
</MudNavGroup>
</MudNavGroup>
@if (ShowFinanceComparison)
{ {
<MudNavLink Href="finance-cockpit/vergleich" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.CompareArrows"> <NavMenuNode Item="item"
@T("Soll/Ist Vergleich", "Actual/reference comparison") Items="_visibleItems"
</MudNavLink> HiddenKeys="_hiddenKeys"
OnAction="HandleMenuActionAsync" />
} }
<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">
@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">
@T("Standorte", "Sites")
</MudNavLink>
<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">
@T("Finance Regeln", "Finance rules")
</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>
@if (FinanceAccess.IsEnabled && FinanceAccess.IsUnlocked)
{
<MudButton Variant="Variant.Text" Color="Color.Secondary" Size="Size.Small"
StartIcon="@Icons.Material.Filled.Lock" OnClick="LockFinanceCockpit" Class="ml-3">
@T("Finance sperren", "Lock finance")
</MudButton>
}
</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">
@T("HR Dashboard", "HR dashboard")
</MudNavLink>
<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">
@T("Admin Bereich", "Admin area")
</MudNavLink>
</MudNavMenu> </MudNavMenu>
@code { @code {
private List<NavigationMenuItem> _visibleItems = [];
private readonly HashSet<string> _hiddenKeys = [];
private bool ShowFinanceComparison => Configuration.GetValue("Navigation:ShowFinanceComparison", true); private bool ShowFinanceComparison => Configuration.GetValue("Navigation:ShowFinanceComparison", true);
protected override void OnInitialized() private IEnumerable<NavigationMenuItem> RootItems => _visibleItems
.Where(x => string.IsNullOrWhiteSpace(x.ParentKey))
.Where(x => !_hiddenKeys.Contains(x.Key))
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.TitleDe);
protected override async Task OnInitializedAsync()
{ {
UiText.Changed += HandleLanguageChanged; UiText.Changed += HandleLanguageChanged;
await LoadMenuAsync();
} }
private void LockFinanceCockpit() private async Task LoadMenuAsync()
{
var items = await NavigationMenuService.GetItemsAsync();
var authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var user = authenticationState.User;
var filtered = new List<NavigationMenuItem>();
foreach (var item in items.Where(x => x.IsVisible))
{
if (!await IsAuthorizedAsync(user, item))
continue;
filtered.Add(item);
}
_hiddenKeys.Clear();
if (!ShowFinanceComparison)
_hiddenKeys.Add("finance-comparison");
if (!FinanceAccess.IsEnabled || !FinanceAccess.IsUnlocked)
_hiddenKeys.Add("finance-lock");
_visibleItems = filtered;
}
private async Task<bool> IsAuthorizedAsync(System.Security.Claims.ClaimsPrincipal user, NavigationMenuItem item)
{
if (string.IsNullOrWhiteSpace(item.RequiredPolicy))
return true;
var result = await AuthorizationService.AuthorizeAsync(user, item.RequiredPolicy);
return result.Succeeded;
}
private Task HandleMenuActionAsync(string key)
{
if (key == "finance-lock")
{ {
FinanceAccess.Lock(); FinanceAccess.Lock();
Navigation.NavigateTo(string.Empty); Navigation.NavigateTo(string.Empty);
} }
return Task.CompletedTask;
}
private void HandleLanguageChanged() private void HandleLanguageChanged()
{ {
InvokeAsync(StateHasChanged); InvokeAsync(StateHasChanged);
} }
private string T(string german, string english) => UiText.Text(german, english);
public void Dispose() public void Dispose()
{ {
UiText.Changed -= HandleLanguageChanged; UiText.Changed -= HandleLanguageChanged;
@@ -0,0 +1,51 @@
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@using Microsoft.AspNetCore.Components.Routing
@if (Item.ItemType == NavigationMenuItemTypes.Group)
{
<MudNavGroup Title="@Title" Icon="@Icon" Expanded="@Item.IsExpanded">
@foreach (var child in Children)
{
<NavMenuNode Item="child"
Items="Items"
HiddenKeys="HiddenKeys"
OnAction="OnAction" />
}
</MudNavGroup>
}
else if (Item.ItemType == NavigationMenuItemTypes.Action)
{
<MudButton Variant="Variant.Text" Color="Color.Secondary" Size="Size.Small"
StartIcon="@Icon" OnClick="() => OnAction.InvokeAsync(Item.Key)" Class="ml-3">
@Title
</MudButton>
}
else
{
<MudNavLink Href="@Item.Href" Match="@Match" Icon="@Icon">
@Title
</MudNavLink>
}
@code {
[Parameter, EditorRequired] public NavigationMenuItem Item { get; set; } = default!;
[Parameter, EditorRequired] public IReadOnlyList<NavigationMenuItem> Items { get; set; } = [];
[Parameter] public HashSet<string> HiddenKeys { get; set; } = [];
[Parameter] public EventCallback<string> OnAction { get; set; }
private string Title => UiText.Text(Item.TitleDe, Item.TitleEn);
private string Icon => NavigationIconResolver.Resolve(Item.Icon);
private NavLinkMatch Match => string.Equals(Item.Match, "All", StringComparison.OrdinalIgnoreCase)
? NavLinkMatch.All
: NavLinkMatch.Prefix;
[Inject] private IUiTextService UiText { get; set; } = default!;
private IEnumerable<NavigationMenuItem> Children => Items
.Where(x => x.IsVisible)
.Where(x => !HiddenKeys.Contains(x.Key))
.Where(x => string.Equals(x.ParentKey, Item.Key, StringComparison.OrdinalIgnoreCase))
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.TitleDe);
}
@@ -0,0 +1,295 @@
@page "/admin/menu-structure"
@using Microsoft.AspNetCore.Authorization
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Security
@using TrafagSalesExporter.Services
@attribute [Authorize(Policy = SecurityPolicies.AdminOnly)]
@inject INavigationMenuService NavigationMenuService
@inject IUiTextService UiText
@inject ISnackbar Snackbar
<PageTitle>@T("Menuestruktur", "Menu structure")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("Menuestruktur", "Menu structure")</MudText>
<MudStack Row="true" Spacing="2" Class="mb-3">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Save" OnClick="SaveAsync" Disabled="_loading">
@T("Speichern", "Save")
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Restore" OnClick="ResetAsync" Disabled="_loading">
@T("Standard wiederherstellen", "Restore default")
</MudButton>
</MudStack>
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-4">
@T("Bestehende Menuepunkte koennen in andere Gruppen gehaengt, sortiert, aus- oder eingeblendet und umbenannt werden. Die Zielseite bleibt unveraendert.",
"Existing menu entries can be moved into other groups, sorted, hidden/shown and renamed. The target page stays unchanged.")
</MudAlert>
@if (_loading)
{
<MudProgressCircular Indeterminate="true" />
}
else
{
<MudText Typo="Typo.h6" Class="mb-2">@T("Drag & Drop", "Drag & drop")</MudText>
<div class="menu-drop-root"
@ondragover:preventDefault
@ondrop="DropOnRoot">
@T("Hier ablegen fuer Hauptmenue", "Drop here for root menu")
</div>
<MudPaper Class="pa-2 mb-4" Outlined="true">
@foreach (var item in OrderedItems)
{
<div class="menu-drag-row"
draggable="true"
@ondragstart="() => StartDrag(item)"
@ondragover:preventDefault
@ondrop="() => DropOn(item)">
<MudIcon Icon="@NavigationIconResolver.Resolve(item.Icon)" Size="Size.Small" />
<span class="menu-drag-title">@Indent(item)@item.TitleDe</span>
<span class="menu-drag-meta">@item.ItemType</span>
</div>
}
</MudPaper>
<MudText Typo="Typo.h6" Class="mb-2">@T("Details", "Details")</MudText>
<MudTable Items="@OrderedItems" Dense="true" Hover="true">
<HeaderContent>
<MudTh>@T("Reihenfolge", "Order")</MudTh>
<MudTh>@T("Titel", "Title")</MudTh>
<MudTh>@T("Typ", "Type")</MudTh>
<MudTh>@T("Untermenue von", "Parent")</MudTh>
<MudTh>@T("Sichtbar", "Visible")</MudTh>
<MudTh>@T("Aktion", "Action")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.KeyboardArrowUp" Size="Size.Small" OnClick="@(() => Move(context, -1))" />
<MudIconButton Icon="@Icons.Material.Filled.KeyboardArrowDown" Size="Size.Small" OnClick="@(() => Move(context, 1))" />
</MudTd>
<MudTd>
<MudTextField @bind-Value="context.TitleDe" Label="DE" Dense="true" Margin="Margin.Dense" />
<MudTextField @bind-Value="context.TitleEn" Label="EN" Dense="true" Margin="Margin.Dense" />
@if (!string.IsNullOrWhiteSpace(context.Href))
{
<MudText Typo="Typo.caption">@context.Href</MudText>
}
</MudTd>
<MudTd>@context.ItemType</MudTd>
<MudTd>
<MudSelect T="string" Value="@NormalizeParent(context.ParentKey)" ValueChanged="value => ChangeParent(context, value)" Dense="true">
<MudSelectItem Value="@RootParentValue">@T("Hauptmenue", "Root menu")</MudSelectItem>
@foreach (var group in ParentOptions(context))
{
<MudSelectItem Value="@group.Key">@GroupLabel(group)</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSwitch @bind-Value="context.IsVisible" Color="Color.Primary" />
</MudTd>
<MudTd>
<MudText Typo="Typo.caption">@context.Key</MudText>
</MudTd>
</RowTemplate>
</MudTable>
}
@code {
private const string RootParentValue = "__root__";
private List<NavigationMenuItem> _items = [];
private NavigationMenuItem? _draggedItem;
private bool _loading = true;
private IEnumerable<NavigationMenuItem> OrderedItems => _items
.OrderBy(x => x.ParentKey ?? string.Empty)
.ThenBy(x => x.SortOrder)
.ThenBy(x => x.TitleDe);
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
_loading = true;
_items = await NavigationMenuService.GetItemsAsync();
_loading = false;
}
private async Task SaveAsync()
{
NormalizeSortOrders();
await NavigationMenuService.SaveItemsAsync(_items);
Snackbar.Add(T("Menuestruktur gespeichert.", "Menu structure saved."), Severity.Success);
}
private async Task ResetAsync()
{
await NavigationMenuService.ResetToDefaultsAsync();
await LoadAsync();
Snackbar.Add(T("Standard-Menuestruktur wiederhergestellt.", "Default menu structure restored."), Severity.Info);
}
private IEnumerable<NavigationMenuItem> ParentOptions(NavigationMenuItem item)
=> _items
.Where(x => x.ItemType == NavigationMenuItemTypes.Group)
.Where(x => x.Key != item.Key)
.Where(x => !WouldCreateCycle(item.Key, x.Key))
.OrderBy(x => x.TitleDe);
private void ChangeParent(NavigationMenuItem item, string parentKey)
{
item.ParentKey = parentKey == RootParentValue ? null : parentKey;
item.SortOrder = NextSortOrder(item.ParentKey);
}
private void Move(NavigationMenuItem item, int direction)
{
var siblings = _items
.Where(x => string.Equals(x.ParentKey, item.ParentKey, StringComparison.OrdinalIgnoreCase))
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.TitleDe)
.ToList();
var index = siblings.FindIndex(x => x.Key == item.Key);
var targetIndex = index + direction;
if (index < 0 || targetIndex < 0 || targetIndex >= siblings.Count)
return;
(siblings[index].SortOrder, siblings[targetIndex].SortOrder) = (siblings[targetIndex].SortOrder, siblings[index].SortOrder);
}
private void StartDrag(NavigationMenuItem item)
{
_draggedItem = item;
}
private void DropOnRoot()
{
if (_draggedItem is null)
return;
_draggedItem.ParentKey = null;
_draggedItem.SortOrder = NextSortOrder(null);
_draggedItem = null;
}
private void DropOn(NavigationMenuItem target)
{
if (_draggedItem is null || _draggedItem.Key == target.Key)
return;
if (target.ItemType == NavigationMenuItemTypes.Group && !WouldCreateCycle(_draggedItem.Key, target.Key))
{
_draggedItem.ParentKey = target.Key;
_draggedItem.SortOrder = NextSortOrder(target.Key);
}
else
{
_draggedItem.ParentKey = target.ParentKey;
_draggedItem.SortOrder = target.SortOrder - 1;
NormalizeSortOrders();
}
_draggedItem = null;
}
private bool WouldCreateCycle(string itemKey, string candidateParentKey)
{
var current = _items.FirstOrDefault(x => x.Key == candidateParentKey);
while (current is not null)
{
if (current.Key == itemKey)
return true;
current = string.IsNullOrWhiteSpace(current.ParentKey)
? null
: _items.FirstOrDefault(x => x.Key == current.ParentKey);
}
return false;
}
private int NextSortOrder(string? parentKey)
=> _items
.Where(x => string.Equals(x.ParentKey, parentKey, StringComparison.OrdinalIgnoreCase))
.Select(x => x.SortOrder)
.DefaultIfEmpty(0)
.Max() + 10;
private void NormalizeSortOrders()
{
foreach (var group in _items.GroupBy(x => x.ParentKey ?? string.Empty))
{
var sortOrder = 10;
foreach (var item in group.OrderBy(x => x.SortOrder).ThenBy(x => x.TitleDe))
{
item.SortOrder = sortOrder;
sortOrder += 10;
}
}
}
private static string NormalizeParent(string? parentKey)
=> string.IsNullOrWhiteSpace(parentKey) ? RootParentValue : parentKey;
private string GroupLabel(NavigationMenuItem item)
=> UiText.Text(item.TitleDe, item.TitleEn);
private string T(string german, string english) => UiText.Text(german, english);
private string Indent(NavigationMenuItem item)
{
var depth = 0;
var current = item;
while (!string.IsNullOrWhiteSpace(current.ParentKey))
{
depth++;
var next = _items.FirstOrDefault(x => x.Key == current.ParentKey);
if (next is null || next.Key == current.Key)
break;
current = next;
}
return new string(' ', depth * 4);
}
}
<style>
.menu-drop-root {
border: 1px dashed var(--mud-palette-lines-default);
border-radius: 4px;
padding: 8px 10px;
margin-bottom: 8px;
color: var(--mud-palette-text-secondary);
font-size: 0.875rem;
}
.menu-drag-row {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-height: 34px;
padding: 4px 8px;
border-bottom: 1px solid var(--mud-palette-lines-default);
cursor: grab;
}
.menu-drag-row:last-child {
border-bottom: 0;
}
.menu-drag-title {
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
}
.menu-drag-meta {
color: var(--mud-palette-text-secondary);
font-size: 0.75rem;
}
</style>
@@ -0,0 +1,23 @@
@page "/einkauf"
@inject TrafagSalesExporter.Services.IUiTextService UiText
<PageTitle>@T("Einkauf", "Purchasing")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("Einkauf", "Purchasing")</MudText>
<MudPaper Class="pa-4" Outlined="true">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.ShoppingCart" Color="Color.Primary" Size="Size.Large" />
<div>
<MudText Typo="Typo.h6">@T("Einkauf Dashboard", "Purchasing dashboard")</MudText>
<MudText Typo="Typo.body2">
@T("Dieser Bereich ist als Einstiegspunkt fuer Einkaufsauswertungen vorbereitet.",
"This area is prepared as the entry point for purchasing analytics.")
</MudText>
</div>
</MudStack>
</MudPaper>
@code {
private string T(string german, string english) => UiText.Text(german, english);
}
+1
View File
@@ -24,4 +24,5 @@ public class AppDbContext : DbContext
public DbSet<SapFieldMapping> SapFieldMappings => Set<SapFieldMapping>(); public DbSet<SapFieldMapping> SapFieldMappings => Set<SapFieldMapping>();
public DbSet<ManualExcelColumnMapping> ManualExcelColumnMappings => Set<ManualExcelColumnMapping>(); public DbSet<ManualExcelColumnMapping> ManualExcelColumnMappings => Set<ManualExcelColumnMapping>();
public DbSet<CentralSalesRecord> CentralSalesRecords => Set<CentralSalesRecord>(); public DbSet<CentralSalesRecord> CentralSalesRecords => Set<CentralSalesRecord>();
public DbSet<NavigationMenuItem> NavigationMenuItems => Set<NavigationMenuItem>();
} }
@@ -0,0 +1,26 @@
namespace TrafagSalesExporter.Models;
public class NavigationMenuItem
{
public int Id { get; set; }
public string Key { get; set; } = string.Empty;
public string? ParentKey { get; set; }
public string TitleDe { get; set; } = string.Empty;
public string TitleEn { get; set; } = string.Empty;
public string Icon { get; set; } = string.Empty;
public string Href { get; set; } = string.Empty;
public string ItemType { get; set; } = NavigationMenuItemTypes.Link;
public string Match { get; set; } = "Prefix";
public string RequiredPolicy { get; set; } = string.Empty;
public bool IsVisible { get; set; } = true;
public bool IsExpanded { get; set; }
public bool IsSystem { get; set; } = true;
public int SortOrder { get; set; }
}
public static class NavigationMenuItemTypes
{
public const string Group = "Group";
public const string Link = "Link";
public const string Action = "Action";
}
+1
View File
@@ -91,6 +91,7 @@ builder.Services.AddSingleton<IDatabaseInitializationService, DatabaseInitializa
builder.Services.AddSingleton<IUiTextService, UiTextService>(); builder.Services.AddSingleton<IUiTextService, UiTextService>();
builder.Services.AddSingleton<IAccessSessionTracker, AccessSessionTracker>(); builder.Services.AddSingleton<IAccessSessionTracker, AccessSessionTracker>();
builder.Services.AddSingleton<ILandingPageSettingsService, LandingPageSettingsService>(); builder.Services.AddSingleton<ILandingPageSettingsService, LandingPageSettingsService>();
builder.Services.AddSingleton<INavigationMenuService, NavigationMenuService>();
// Datenquellen-Adapter (Strategy per ConnectionKind). // Datenquellen-Adapter (Strategy per ConnectionKind).
builder.Services.AddSingleton<IDataSourceAdapter, HanaDataSourceAdapter>(); builder.Services.AddSingleton<IDataSourceAdapter, HanaDataSourceAdapter>();
@@ -215,4 +215,22 @@ CREATE TABLE FinanceRules (
SortOrder INTEGER NOT NULL DEFAULT 0, SortOrder INTEGER NOT NULL DEFAULT 0,
IsActive INTEGER NOT NULL DEFAULT 1 IsActive INTEGER NOT NULL DEFAULT 1
);"; );";
internal static string GetNavigationMenuItemsCreateSql() => @"
CREATE TABLE NavigationMenuItems (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
Key TEXT NOT NULL,
ParentKey TEXT NULL,
TitleDe TEXT NOT NULL DEFAULT '',
TitleEn TEXT NOT NULL DEFAULT '',
Icon TEXT NOT NULL DEFAULT '',
Href TEXT NOT NULL DEFAULT '',
ItemType TEXT NOT NULL DEFAULT 'Link',
Match TEXT NOT NULL DEFAULT 'Prefix',
RequiredPolicy TEXT NOT NULL DEFAULT '',
IsVisible INTEGER NOT NULL DEFAULT 1,
IsExpanded INTEGER NOT NULL DEFAULT 0,
IsSystem INTEGER NOT NULL DEFAULT 1,
SortOrder INTEGER NOT NULL DEFAULT 0
);";
} }
@@ -45,6 +45,7 @@ public class DatabaseSchemaMaintenanceService : IDatabaseSchemaMaintenanceServic
EnsureSapFieldMappingTable(db); EnsureSapFieldMappingTable(db);
EnsureManualExcelColumnMappingTable(db); EnsureManualExcelColumnMappingTable(db);
EnsureCentralSalesRecordTable(db); EnsureCentralSalesRecordTable(db);
EnsureNavigationMenuItemTable(db);
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentEntry", "INTEGER NOT NULL DEFAULT 0"); AddColumnIfMissing(db, "CentralSalesRecords", "DocumentEntry", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentCurrency", "TEXT NOT NULL DEFAULT ''"); AddColumnIfMissing(db, "CentralSalesRecords", "DocumentCurrency", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentTotalForeignCurrency", "TEXT NOT NULL DEFAULT '0'"); AddColumnIfMissing(db, "CentralSalesRecords", "DocumentTotalForeignCurrency", "TEXT NOT NULL DEFAULT '0'");
@@ -272,6 +273,17 @@ CREATE TABLE IF NOT EXISTS FieldTransformationRules (
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
private static void EnsureNavigationMenuItemTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = DatabaseSchemaSql.GetNavigationMenuItemsCreateSql().Replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS");
cmd.ExecuteNonQuery();
}
private static void EnsureSapSourceTable(AppDbContext db) private static void EnsureSapSourceTable(AppDbContext db)
{ {
var conn = db.Database.GetDbConnection(); var conn = db.Database.GetDbConnection();
@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data; using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models; using TrafagSalesExporter.Models;
using TrafagSalesExporter.Security;
namespace TrafagSalesExporter.Services; namespace TrafagSalesExporter.Services;
@@ -20,6 +21,7 @@ public class DatabaseSeedService : IDatabaseSeedService
EnsureBudgetExchangeRateDefaults(db); EnsureBudgetExchangeRateDefaults(db);
EnsureFinanceIntercompanyRuleDefaults(db); EnsureFinanceIntercompanyRuleDefaults(db);
EnsureFinanceRuleDefaults(db); EnsureFinanceRuleDefaults(db);
EnsureNavigationMenuDefaults(db);
} }
private static void SeedIfEmpty(AppDbContext db) private static void SeedIfEmpty(AppDbContext db)
@@ -115,6 +117,112 @@ public class DatabaseSeedService : IDatabaseSeedService
db.SaveChanges(); db.SaveChanges();
} }
private static void EnsureNavigationMenuDefaults(AppDbContext db)
{
var defaults = BuildDefaultNavigationMenuItems();
var changed = false;
foreach (var item in defaults)
{
var existing = db.NavigationMenuItems.FirstOrDefault(x => x.Key == item.Key);
if (existing is null)
{
db.NavigationMenuItems.Add(item);
changed = true;
continue;
}
if (string.IsNullOrWhiteSpace(existing.TitleDe)) existing.TitleDe = item.TitleDe;
if (string.IsNullOrWhiteSpace(existing.TitleEn)) existing.TitleEn = item.TitleEn;
if (string.IsNullOrWhiteSpace(existing.Icon)) existing.Icon = item.Icon;
if (string.IsNullOrWhiteSpace(existing.Href)) existing.Href = item.Href;
if (string.IsNullOrWhiteSpace(existing.ItemType)) existing.ItemType = item.ItemType;
if (string.IsNullOrWhiteSpace(existing.Match)) existing.Match = item.Match;
if (string.IsNullOrWhiteSpace(existing.RequiredPolicy)) existing.RequiredPolicy = item.RequiredPolicy;
existing.IsSystem = true;
changed = true;
}
if (changed)
db.SaveChanges();
}
private static List<NavigationMenuItem> BuildDefaultNavigationMenuItems() =>
[
Group("finance", null, "Finance Cockpit", "Finance Cockpit", "Analytics", 10, expanded: true),
Link("export-dashboard", "finance", "Export Dashboard", "Export dashboard", "Dashboard", "export-dashboard", 10),
Group("management-analysis", "finance", "Management Analyse", "Management analysis", "QueryStats", 20),
Link("management-quick", "management-analysis", "Schnelluebersicht", "Quick overview", "Speed", "management-cockpit", 10, "All"),
Group("experts", "management-analysis", "Experten", "Experts", "Tune", 20),
Link("finance-summary", "experts", "Finance Summary", "Finance summary", "Dashboard", "management-cockpit?section=summary", 10, "All"),
Link("country-diagnostics", "experts", "Laender Diagnose", "Country diagnostics", "Public", "management-cockpit?section=countries", 20, "All"),
Link("data-status", "experts", "Datenstatus", "Data status", "FactCheck", "management-cockpit?section=status", 30, "All"),
Link("deviations", "experts", "Abweichungen", "Deviations", "WarningAmber", "management-cockpit?section=deviations", 40, "All"),
Link("credits", "experts", "Gutschriften", "Credit notes", "AssignmentReturn", "management-cockpit?section=credits", 50, "All"),
Link("data-quality", "experts", "Datenqualitaet", "Data quality", "Rule", "management-cockpit?section=quality", 60, "All"),
Link("division-finance", "experts", "Sparten-Finanzanalyse", "Division finance", "PieChart", "management-cockpit?section=division&division=finance", 70, "All"),
Link("division-central", "experts", "Zentrale Spartenzuordnung", "Central division mapping", "AccountTree", "management-cockpit?section=division&division=central", 80, "All"),
Link("finance-3d", "experts", "3D Datenanalyse", "3D data analysis", "ViewInAr", "management-cockpit?section=3d", 90, "All"),
Link("raw-diagnostics", "experts", "Rohdaten Diagnose", "Raw-data diagnostics", "QueryStats", "management-cockpit?section=raw", 100, "All"),
Link("finance-comparison", "finance", "Soll/Ist Vergleich", "Actual/reference comparison", "CompareArrows", "finance-cockpit/vergleich", 30),
Link("finance-training", "finance", "Finance Schulung", "Finance training", "School", "finance-cockpit/schulung", 40),
Link("manual-imports", "finance", "Manuelle Importe", "Manual imports", "UploadFile", "manual-imports", 50),
Group("finance-admin", "finance", "Admin", "Admin", "AdminPanelSettings", 60),
Link("sites", "finance-admin", "Standorte", "Sites", "LocationOn", "standorte", 10, requiredPolicy: SecurityPolicies.AdminOnly),
Link("transformations", "finance-admin", "Transformationen", "Transformations", "Transform", "transformations", 20, requiredPolicy: SecurityPolicies.AdminOnly),
Link("finance-rules", "finance-admin", "Finance Regeln", "Finance rules", "Rule", "finance-rules", 30, requiredPolicy: SecurityPolicies.AdminOnly),
Link("settings", "finance-admin", "Settings", "Settings", "Settings", "settings", 40, requiredPolicy: SecurityPolicies.AdminOnly),
Link("menu-structure", "finance-admin", "Menuestruktur", "Menu structure", "AccountTree", "admin/menu-structure", 45, requiredPolicy: SecurityPolicies.AdminOnly),
Link("logs", "finance-admin", "Logs", "Logs", "List", "logs", 50),
Action("finance-lock", "finance", "Finance sperren", "Lock finance", "Lock", 70),
Group("hr", null, "HR KPI (Login)", "HR KPI (login)", "Groups", 20),
Link("hr-dashboard", "hr", "HR Dashboard", "HR dashboard", "Dashboard", "hr-kpi", 10, "All"),
Link("hr-training", "hr", "HR KPI Schulung", "HR KPI training", "School", "hr-kpi/schulung", 20),
Group("purchasing", null, "Einkauf", "Purchasing", "ShoppingCart", 30),
Link("purchasing-dashboard", "purchasing", "Einkauf Dashboard", "Purchasing dashboard", "Dashboard", "einkauf", 10, "All"),
Link("admin-sessions", null, "Admin Bereich", "Admin area", "PeopleAlt", "admin/sessions", 90)
];
private static NavigationMenuItem Group(string key, string? parentKey, string titleDe, string titleEn, string icon, int sortOrder, bool expanded = false)
=> new()
{
Key = key,
ParentKey = parentKey,
TitleDe = titleDe,
TitleEn = titleEn,
Icon = icon,
ItemType = NavigationMenuItemTypes.Group,
IsExpanded = expanded,
SortOrder = sortOrder
};
private static NavigationMenuItem Link(string key, string? parentKey, string titleDe, string titleEn, string icon, string href, int sortOrder, string match = "Prefix", string requiredPolicy = "")
=> new()
{
Key = key,
ParentKey = parentKey,
TitleDe = titleDe,
TitleEn = titleEn,
Icon = icon,
Href = href,
Match = match,
RequiredPolicy = requiredPolicy,
ItemType = NavigationMenuItemTypes.Link,
SortOrder = sortOrder
};
private static NavigationMenuItem Action(string key, string? parentKey, string titleDe, string titleEn, string icon, int sortOrder)
=> new()
{
Key = key,
ParentKey = parentKey,
TitleDe = titleDe,
TitleEn = titleEn,
Icon = icon,
ItemType = NavigationMenuItemTypes.Action,
SortOrder = sortOrder
};
private static void EnsureCentralHanaServerRecords(AppDbContext db) private static void EnsureCentralHanaServerRecords(AppDbContext db)
{ {
var centralSystems = db.SourceSystemDefinitions var centralSystems = db.SourceSystemDefinitions
@@ -0,0 +1,10 @@
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface INavigationMenuService
{
Task<List<NavigationMenuItem>> GetItemsAsync();
Task SaveItemsAsync(IEnumerable<NavigationMenuItem> items);
Task ResetToDefaultsAsync();
}
@@ -0,0 +1,36 @@
using MudBlazor;
namespace TrafagSalesExporter.Services;
public static class NavigationIconResolver
{
public static string Resolve(string icon) => icon switch
{
"AccountTree" => Icons.Material.Filled.AccountTree,
"AdminPanelSettings" => Icons.Material.Filled.AdminPanelSettings,
"Analytics" => Icons.Material.Filled.Analytics,
"AssignmentReturn" => Icons.Material.Filled.AssignmentReturn,
"CompareArrows" => Icons.Material.Filled.CompareArrows,
"Dashboard" => Icons.Material.Filled.Dashboard,
"FactCheck" => Icons.Material.Filled.FactCheck,
"Groups" => Icons.Material.Filled.Groups,
"List" => Icons.Material.Filled.List,
"LocationOn" => Icons.Material.Filled.LocationOn,
"Lock" => Icons.Material.Filled.Lock,
"PeopleAlt" => Icons.Material.Filled.PeopleAlt,
"PieChart" => Icons.Material.Filled.PieChart,
"Public" => Icons.Material.Filled.Public,
"QueryStats" => Icons.Material.Filled.QueryStats,
"Rule" => Icons.Material.Filled.Rule,
"School" => Icons.Material.Filled.School,
"Settings" => Icons.Material.Filled.Settings,
"ShoppingCart" => Icons.Material.Filled.ShoppingCart,
"Speed" => Icons.Material.Filled.Speed,
"Transform" => Icons.Material.Filled.Transform,
"Tune" => Icons.Material.Filled.Tune,
"UploadFile" => Icons.Material.Filled.UploadFile,
"ViewInAr" => Icons.Material.Filled.ViewInAr,
"WarningAmber" => Icons.Material.Filled.WarningAmber,
_ => Icons.Material.Filled.Circle
};
}
@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public sealed class NavigationMenuService : INavigationMenuService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
public NavigationMenuService(IDbContextFactory<AppDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public async Task<List<NavigationMenuItem>> GetItemsAsync()
{
await using var db = await _dbFactory.CreateDbContextAsync();
return await db.NavigationMenuItems
.AsNoTracking()
.OrderBy(x => x.ParentKey ?? string.Empty)
.ThenBy(x => x.SortOrder)
.ThenBy(x => x.TitleDe)
.ToListAsync();
}
public async Task SaveItemsAsync(IEnumerable<NavigationMenuItem> items)
{
await using var db = await _dbFactory.CreateDbContextAsync();
var incoming = items.ToDictionary(x => x.Key, StringComparer.OrdinalIgnoreCase);
var existing = await db.NavigationMenuItems.ToListAsync();
foreach (var item in existing)
{
if (!incoming.TryGetValue(item.Key, out var source))
continue;
item.ParentKey = string.IsNullOrWhiteSpace(source.ParentKey) ? null : source.ParentKey;
item.SortOrder = source.SortOrder;
item.IsVisible = source.IsVisible;
item.IsExpanded = source.IsExpanded;
item.TitleDe = source.TitleDe.Trim();
item.TitleEn = source.TitleEn.Trim();
}
await db.SaveChangesAsync();
}
public async Task ResetToDefaultsAsync()
{
await using var db = await _dbFactory.CreateDbContextAsync();
db.NavigationMenuItems.RemoveRange(db.NavigationMenuItems);
await db.SaveChangesAsync();
new DatabaseSeedService().SeedDefaults(db);
}
}
+2
View File
@@ -9,6 +9,8 @@ Stand: 2026-06-05
- Validierung laut Doku: Finance-Sitzungsstand `82/82` Tests gruen; spaetere UI-/Deploy-Schritte wurden einzeln umgesetzt und deployed. - Validierung laut Doku: Finance-Sitzungsstand `82/82` Tests gruen; spaetere UI-/Deploy-Schritte wurden einzeln umgesetzt und deployed.
- Letzter dokumentierter Finance-Deploy: 2026-06-05 auf `\\trch-webapp-bidashboard.trafagch.local\BiDashboard$\`. - Letzter dokumentierter Finance-Deploy: 2026-06-05 auf `\\trch-webapp-bidashboard.trafagch.local\BiDashboard$\`.
- Neu im Finance/Management-Cockpit: einfache Schnelluebersicht links sichtbar; tiefere Funktionen bleiben unter `Experten`. - Neu im Finance/Management-Cockpit: einfache Schnelluebersicht links sichtbar; tiefere Funktionen bleiben unter `Experten`.
- Neu in der Navigation: Menuebaum wird aus `NavigationMenuItems` gerendert; Admins koennen bestehende Punkte unter `Admin > Menuestruktur` umhaengen, sortieren und aus-/einblenden.
- Neu als Hauptbereich: `Einkauf` mit Einkaufswagen-Icon und Einstieg `Einkauf Dashboard`.
- Neu im Expertenbereich: `3D Datenanalyse` mit drehbarer 3D-Grafik, Achsen, Diagrammarten, Indikatorauswahl, Labelgroesse und Simulation per Schieberegler. - Neu im Expertenbereich: `3D Datenanalyse` mit drehbarer 3D-Grafik, Achsen, Diagrammarten, Indikatorauswahl, Labelgroesse und Simulation per Schieberegler.
- Spanien: `Run-SpainRangeExportAndUpload-AllInOne.ps1` exportiert Sage-Range direkt und laedt CSV/Summary via rclone nach SharePoint `trafag-bi:Import/Finance/Spanien`. - Spanien: `Run-SpainRangeExportAndUpload-AllInOne.ps1` exportiert Sage-Range direkt und laedt CSV/Summary via rclone nach SharePoint `trafag-bi:Import/Finance/Spanien`.
- Spanien: Default-Range ist heute minus 7 Tage bis heute; `ToDate` ist exklusiv. - Spanien: Default-Range ist heute minus 7 Tage bis heute; `ToDate` ist exklusiv.
+2
View File
@@ -48,6 +48,8 @@ Diese Datei ist fuer tokenarme RAG-Nutzung komprimiert.
- Letzter dokumentierter Finance-Deploy: 2026-06-04 nach 3D-Datenanalyse-/Schnelluebersicht-Anpassungen auf `\\trch-webapp-bidashboard.trafagch.local\BiDashboard$\`. - Letzter dokumentierter Finance-Deploy: 2026-06-04 nach 3D-Datenanalyse-/Schnelluebersicht-Anpassungen auf `\\trch-webapp-bidashboard.trafagch.local\BiDashboard$\`.
- Aktueller Stand 2026-06-05: Spanien-Scriptfixes sind committed; Server muss die aktuelle All-in-one-PS1 verwenden, nicht alte Kopien mit `(1)` und nicht den alten Wrapper. - Aktueller Stand 2026-06-05: Spanien-Scriptfixes sind committed; Server muss die aktuelle All-in-one-PS1 verwenden, nicht alte Kopien mit `(1)` und nicht den alten Wrapper.
- Spanien-Delta-Sync im Dashboard-Import wurde am 2026-06-05 publiziert. Publish brauchte kurz `app_offline.htm`, weil `BiDashboard.dll` gesperrt war; danach wurde `app_offline.htm` wieder entfernt. - Spanien-Delta-Sync im Dashboard-Import wurde am 2026-06-05 publiziert. Publish brauchte kurz `app_offline.htm`, weil `BiDashboard.dll` gesperrt war; danach wurde `app_offline.htm` wieder entfernt.
- Neu umgesetzt: Linker Menuebaum ist datengetrieben und ueber `Admin > Menuestruktur` umhaengbar/sortierbar; bestehende Punkte koennen in andere Untermenues verschoben werden.
- Neu umgesetzt: Neuer Hauptpunkt `Einkauf` mit Einkaufswagen-Icon und vorbereiteter Einstiegseite `Einkauf Dashboard`.
- Letzte Validierung: `dotnet test TrafagSalesExporter.sln --verbosity minimal --artifacts-path C:\TMP\trafag-test-artifacts-finance-session-proof` mit `82/82` Tests gruen. - Letzte Validierung: `dotnet test TrafagSalesExporter.sln --verbosity minimal --artifacts-path C:\TMP\trafag-test-artifacts-finance-session-proof` mit `82/82` Tests gruen.
## Nachtrag 2026-06-05 Spanien Sage / rclone Upload ## Nachtrag 2026-06-05 Spanien Sage / rclone Upload