diff --git a/TrafagSalesExporter/Components/Layout/NavMenu.razor b/TrafagSalesExporter/Components/Layout/NavMenu.razor index 1e9c7a3..bdbcfe5 100644 --- a/TrafagSalesExporter/Components/Layout/NavMenu.razor +++ b/TrafagSalesExporter/Components/Layout/NavMenu.razor @@ -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 - - - @T("Export Dashboard", "Export dashboard") - - - - @T("Schnelluebersicht", "Quick overview") - - - - @T("Finance Summary", "Finance summary") - - - @T("Laender Diagnose", "Country diagnostics") - - - @T("Datenstatus", "Data status") - - - @T("Abweichungen", "Deviations") - - - @T("Gutschriften", "Credit notes") - - - @T("Datenqualitaet", "Data quality") - - - @T("Sparten-Finanzanalyse", "Division finance") - - - @T("Zentrale Spartenzuordnung", "Central division mapping") - - - @T("3D Datenanalyse", "3D data analysis") - - - @T("Rohdaten Diagnose", "Raw-data diagnostics") - - - - @if (ShowFinanceComparison) - { - - @T("Soll/Ist Vergleich", "Actual/reference comparison") - - } - - @T("Finance Schulung", "Finance training") - - - @T("Manuelle Importe", "Manual imports") - - - - - - @T("Standorte", "Sites") - - - @T("Transformationen", "Transformations") - - - @T("Finance Regeln", "Finance rules") - - - @T("Settings", "Settings") - - - - - @T("Logs", "Logs") - - - @if (FinanceAccess.IsEnabled && FinanceAccess.IsUnlocked) - { - - @T("Finance sperren", "Lock finance") - - } - - - - @T("HR Dashboard", "HR dashboard") - - - @T("HR KPI Schulung", "HR KPI training") - - - - @T("Admin Bereich", "Admin area") - + @foreach (var item in RootItems) + { + + } @code { + private List _visibleItems = []; + private readonly HashSet _hiddenKeys = []; + private bool ShowFinanceComparison => Configuration.GetValue("Navigation:ShowFinanceComparison", true); - protected override void OnInitialized() + private IEnumerable 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(); + + 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 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; diff --git a/TrafagSalesExporter/Components/Layout/NavMenuNode.razor b/TrafagSalesExporter/Components/Layout/NavMenuNode.razor new file mode 100644 index 0000000..7c35f23 --- /dev/null +++ b/TrafagSalesExporter/Components/Layout/NavMenuNode.razor @@ -0,0 +1,51 @@ +@using TrafagSalesExporter.Models +@using TrafagSalesExporter.Services +@using Microsoft.AspNetCore.Components.Routing + +@if (Item.ItemType == NavigationMenuItemTypes.Group) +{ + + @foreach (var child in Children) + { + + } + +} +else if (Item.ItemType == NavigationMenuItemTypes.Action) +{ + + @Title + +} +else +{ + + @Title + +} + +@code { + [Parameter, EditorRequired] public NavigationMenuItem Item { get; set; } = default!; + [Parameter, EditorRequired] public IReadOnlyList Items { get; set; } = []; + [Parameter] public HashSet HiddenKeys { get; set; } = []; + [Parameter] public EventCallback 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 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); +} diff --git a/TrafagSalesExporter/Components/Pages/MenuStructure.razor b/TrafagSalesExporter/Components/Pages/MenuStructure.razor new file mode 100644 index 0000000..cce4116 --- /dev/null +++ b/TrafagSalesExporter/Components/Pages/MenuStructure.razor @@ -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 + +@T("Menuestruktur", "Menu structure") + +@T("Menuestruktur", "Menu structure") + + + + @T("Speichern", "Save") + + + @T("Standard wiederherstellen", "Restore default") + + + + + @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.") + + +@if (_loading) +{ + +} +else +{ + @T("Drag & Drop", "Drag & drop") + + + @foreach (var item in OrderedItems) + { + + } + + + @T("Details", "Details") + + + @T("Reihenfolge", "Order") + @T("Titel", "Title") + @T("Typ", "Type") + @T("Untermenue von", "Parent") + @T("Sichtbar", "Visible") + @T("Aktion", "Action") + + + + + + + + + + @if (!string.IsNullOrWhiteSpace(context.Href)) + { + @context.Href + } + + @context.ItemType + + + @T("Hauptmenue", "Root menu") + @foreach (var group in ParentOptions(context)) + { + @GroupLabel(group) + } + + + + + + + @context.Key + + + +} + +@code { + private const string RootParentValue = "__root__"; + private List _items = []; + private NavigationMenuItem? _draggedItem; + private bool _loading = true; + + private IEnumerable 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 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); + } +} + + diff --git a/TrafagSalesExporter/Components/Pages/PurchasingDashboard.razor b/TrafagSalesExporter/Components/Pages/PurchasingDashboard.razor new file mode 100644 index 0000000..bd39503 --- /dev/null +++ b/TrafagSalesExporter/Components/Pages/PurchasingDashboard.razor @@ -0,0 +1,23 @@ +@page "/einkauf" +@inject TrafagSalesExporter.Services.IUiTextService UiText + +@T("Einkauf", "Purchasing") + +@T("Einkauf", "Purchasing") + + + + +
+ @T("Einkauf Dashboard", "Purchasing dashboard") + + @T("Dieser Bereich ist als Einstiegspunkt fuer Einkaufsauswertungen vorbereitet.", + "This area is prepared as the entry point for purchasing analytics.") + +
+
+
+ +@code { + private string T(string german, string english) => UiText.Text(german, english); +} diff --git a/TrafagSalesExporter/Data/AppDbContext.cs b/TrafagSalesExporter/Data/AppDbContext.cs index 64e1f58..3f7504b 100644 --- a/TrafagSalesExporter/Data/AppDbContext.cs +++ b/TrafagSalesExporter/Data/AppDbContext.cs @@ -24,4 +24,5 @@ public class AppDbContext : DbContext public DbSet SapFieldMappings => Set(); public DbSet ManualExcelColumnMappings => Set(); public DbSet CentralSalesRecords => Set(); + public DbSet NavigationMenuItems => Set(); } diff --git a/TrafagSalesExporter/Models/NavigationMenuItem.cs b/TrafagSalesExporter/Models/NavigationMenuItem.cs new file mode 100644 index 0000000..887b0cd --- /dev/null +++ b/TrafagSalesExporter/Models/NavigationMenuItem.cs @@ -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"; +} diff --git a/TrafagSalesExporter/Program.cs b/TrafagSalesExporter/Program.cs index df0211a..47a907d 100644 --- a/TrafagSalesExporter/Program.cs +++ b/TrafagSalesExporter/Program.cs @@ -91,6 +91,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // Datenquellen-Adapter (Strategy per ConnectionKind). builder.Services.AddSingleton(); diff --git a/TrafagSalesExporter/Services/DatabaseInitializationService.SchemaSql.cs b/TrafagSalesExporter/Services/DatabaseInitializationService.SchemaSql.cs index dfab745..b081b57 100644 --- a/TrafagSalesExporter/Services/DatabaseInitializationService.SchemaSql.cs +++ b/TrafagSalesExporter/Services/DatabaseInitializationService.SchemaSql.cs @@ -215,4 +215,22 @@ CREATE TABLE FinanceRules ( SortOrder INTEGER NOT NULL DEFAULT 0, 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 +);"; } diff --git a/TrafagSalesExporter/Services/DatabaseSchemaMaintenanceService.cs b/TrafagSalesExporter/Services/DatabaseSchemaMaintenanceService.cs index 561fab4..9a3c70a 100644 --- a/TrafagSalesExporter/Services/DatabaseSchemaMaintenanceService.cs +++ b/TrafagSalesExporter/Services/DatabaseSchemaMaintenanceService.cs @@ -45,6 +45,7 @@ public class DatabaseSchemaMaintenanceService : IDatabaseSchemaMaintenanceServic EnsureSapFieldMappingTable(db); EnsureManualExcelColumnMappingTable(db); EnsureCentralSalesRecordTable(db); + EnsureNavigationMenuItemTable(db); AddColumnIfMissing(db, "CentralSalesRecords", "DocumentEntry", "INTEGER NOT NULL DEFAULT 0"); AddColumnIfMissing(db, "CentralSalesRecords", "DocumentCurrency", "TEXT NOT NULL DEFAULT ''"); AddColumnIfMissing(db, "CentralSalesRecords", "DocumentTotalForeignCurrency", "TEXT NOT NULL DEFAULT '0'"); @@ -272,6 +273,17 @@ CREATE TABLE IF NOT EXISTS FieldTransformationRules ( 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) { var conn = db.Database.GetDbConnection(); diff --git a/TrafagSalesExporter/Services/DatabaseSeedService.cs b/TrafagSalesExporter/Services/DatabaseSeedService.cs index c059320..610db70 100644 --- a/TrafagSalesExporter/Services/DatabaseSeedService.cs +++ b/TrafagSalesExporter/Services/DatabaseSeedService.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using TrafagSalesExporter.Data; using TrafagSalesExporter.Models; +using TrafagSalesExporter.Security; namespace TrafagSalesExporter.Services; @@ -20,6 +21,7 @@ public class DatabaseSeedService : IDatabaseSeedService EnsureBudgetExchangeRateDefaults(db); EnsureFinanceIntercompanyRuleDefaults(db); EnsureFinanceRuleDefaults(db); + EnsureNavigationMenuDefaults(db); } private static void SeedIfEmpty(AppDbContext db) @@ -115,6 +117,112 @@ public class DatabaseSeedService : IDatabaseSeedService 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 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) { var centralSystems = db.SourceSystemDefinitions diff --git a/TrafagSalesExporter/Services/INavigationMenuService.cs b/TrafagSalesExporter/Services/INavigationMenuService.cs new file mode 100644 index 0000000..1de1f70 --- /dev/null +++ b/TrafagSalesExporter/Services/INavigationMenuService.cs @@ -0,0 +1,10 @@ +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Services; + +public interface INavigationMenuService +{ + Task> GetItemsAsync(); + Task SaveItemsAsync(IEnumerable items); + Task ResetToDefaultsAsync(); +} diff --git a/TrafagSalesExporter/Services/NavigationIconResolver.cs b/TrafagSalesExporter/Services/NavigationIconResolver.cs new file mode 100644 index 0000000..aa050be --- /dev/null +++ b/TrafagSalesExporter/Services/NavigationIconResolver.cs @@ -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 + }; +} diff --git a/TrafagSalesExporter/Services/NavigationMenuService.cs b/TrafagSalesExporter/Services/NavigationMenuService.cs new file mode 100644 index 0000000..a20c81c --- /dev/null +++ b/TrafagSalesExporter/Services/NavigationMenuService.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using TrafagSalesExporter.Data; +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Services; + +public sealed class NavigationMenuService : INavigationMenuService +{ + private readonly IDbContextFactory _dbFactory; + + public NavigationMenuService(IDbContextFactory dbFactory) + { + _dbFactory = dbFactory; + } + + public async Task> 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 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); + } +} diff --git a/TrafagSalesExporter/docs/rag/PROJECT.md b/TrafagSalesExporter/docs/rag/PROJECT.md index b04479d..2a92e23 100644 --- a/TrafagSalesExporter/docs/rag/PROJECT.md +++ b/TrafagSalesExporter/docs/rag/PROJECT.md @@ -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. - 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 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. - 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. diff --git a/TrafagSalesExporter/lastchange.md b/TrafagSalesExporter/lastchange.md index 1999888..91eaf71 100644 --- a/TrafagSalesExporter/lastchange.md +++ b/TrafagSalesExporter/lastchange.md @@ -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$\`. - 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. +- 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. ## Nachtrag 2026-06-05 Spanien Sage / rclone Upload