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,118 +1,87 @@
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Security
@using TrafagSalesExporter.Services
@implements IDisposable
@inject TrafagSalesExporter.Services.IUiTextService UiText
@inject TrafagSalesExporter.Services.IFinanceCockpitAccessService FinanceAccess
@inject IUiTextService UiText
@inject IFinanceCockpitAccessService FinanceAccess
@inject INavigationMenuService NavigationMenuService
@inject IConfiguration Configuration
@inject NavigationManager Navigation
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject IAuthorizationService AuthorizationService
<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">
@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">
@T("Soll/Ist Vergleich", "Actual/reference comparison")
</MudNavLink>
}
<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>
@foreach (var item in RootItems)
{
<NavMenuNode Item="item"
Items="_visibleItems"
HiddenKeys="_hiddenKeys"
OnAction="HandleMenuActionAsync" />
}
</MudNavMenu>
@code {
private List<NavigationMenuItem> _visibleItems = [];
private readonly HashSet<string> _hiddenKeys = [];
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;
await LoadMenuAsync();
}
private void LockFinanceCockpit()
private async Task LoadMenuAsync()
{
FinanceAccess.Lock();
Navigation.NavigateTo(string.Empty);
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();
Navigation.NavigateTo(string.Empty);
}
return Task.CompletedTask;
}
private void HandleLanguageChanged()
@@ -120,8 +89,6 @@
InvokeAsync(StateHasChanged);
}
private string T(string german, string english) => UiText.Text(german, english);
public void Dispose()
{
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);
}