Restructures project with feature-based organization

Refactors project structure to support modular, feature-driven development

Introduces comprehensive language localization support
Adds menu management with role-based access control
Implements dynamic sidebar and theme switching capabilities

Enhances project scalability and maintainability
This commit is contained in:
Janus C. H. Knudsen 2026-01-08 15:44:11 +01:00
parent fac7754d7a
commit d7f3c55a2a
60 changed files with 3214 additions and 20 deletions

View file

@ -0,0 +1,8 @@
namespace CalendarServer.Features.Language.Models;
public class SupportedCulture
{
public required string Code { get; set; }
public required string Name { get; set; }
public required string NativeName { get; set; }
}

View file

@ -0,0 +1,10 @@
using CalendarServer.Features.Language.Models;
namespace CalendarServer.Features.Language.Services;
public interface ILocalizationService
{
string Get(string key, string? culture = null);
string CurrentCulture { get; }
IEnumerable<SupportedCulture> GetSupportedCultures();
}

View file

@ -0,0 +1,48 @@
using System.Text.Json;
using CalendarServer.Features.Language.Models;
namespace CalendarServer.Features.Language.Services;
public class JsonLocalizationService : ILocalizationService
{
private readonly string _translationsPath;
public JsonLocalizationService(IWebHostEnvironment env)
{
_translationsPath = Path.Combine(env.ContentRootPath, "Features", "Language", "Translations");
}
public string CurrentCulture => "en";
public string Get(string key, string? culture = null)
{
culture ??= CurrentCulture;
var filePath = Path.Combine(_translationsPath, $"{culture}.json");
if (!File.Exists(filePath))
return key;
var json = File.ReadAllText(filePath);
var doc = JsonDocument.Parse(json);
var parts = key.Split('.');
JsonElement current = doc.RootElement;
foreach (var part in parts)
{
if (!current.TryGetProperty(part, out current))
return key;
}
return current.GetString() ?? key;
}
public IEnumerable<SupportedCulture> GetSupportedCultures()
{
return new List<SupportedCulture>
{
new() { Code = "da", Name = "Danish", NativeName = "Dansk" },
new() { Code = "en", Name = "English", NativeName = "English" }
};
}
}

View file

@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Razor.TagHelpers;
using CalendarServer.Features.Language.Services;
namespace CalendarServer.Features.Language.TagHelpers;
[HtmlTargetElement(Attributes = "localize")]
public class LocalizeTagHelper : TagHelper
{
private readonly ILocalizationService _localize;
public LocalizeTagHelper(ILocalizationService localize)
{
_localize = localize;
}
[HtmlAttributeName("localize")]
public string Key { get; set; } = string.Empty;
public override void Process(TagHelperContext context, TagHelperOutput output)
{
var translated = _localize.Get(Key);
if (!string.IsNullOrEmpty(translated) && translated != Key)
{
output.Content.SetContent(translated);
}
output.Attributes.RemoveAll("localize");
}
}

View file

@ -0,0 +1,33 @@
{
"menu": {
"home": "Dashboard",
"calendar": "Kalender",
"pos": "Kasse",
"products": "Produkter & Lager",
"suppliers": "Leverandører",
"customers": "Kunder",
"employees": "Medarbejdere",
"reports": "Statistik & Rapporter",
"settings": "Indstillinger",
"account": "Abonnement & Konto"
},
"groups": {
"dashboard": "Dashboard",
"data": "Data",
"analytics": "Analyse",
"system": "System"
},
"common": {
"save": "Gem",
"cancel": "Annuller",
"search": "Søg",
"close": "Luk",
"delete": "Slet",
"edit": "Rediger",
"add": "Tilføj"
},
"sidebar": {
"lockScreen": "Lås skærm",
"appName": "Salon OS"
}
}

View file

@ -0,0 +1,33 @@
{
"menu": {
"home": "Dashboard",
"calendar": "Calendar",
"pos": "Point of Sale",
"products": "Products & Inventory",
"suppliers": "Suppliers",
"customers": "Customers",
"employees": "Employees",
"reports": "Statistics & Reports",
"settings": "Settings",
"account": "Subscription & Account"
},
"groups": {
"dashboard": "Dashboard",
"data": "Data",
"analytics": "Analytics",
"system": "System"
},
"common": {
"save": "Save",
"cancel": "Cancel",
"search": "Search",
"close": "Close",
"delete": "Delete",
"edit": "Edit",
"add": "Add"
},
"sidebar": {
"lockScreen": "Lock screen",
"appName": "Salon OS"
}
}

View file

@ -0,0 +1,12 @@
namespace CalendarServer.Features.Menu.Models;
/// <summary>
/// Represents a group of menu items (e.g., "Dashboard", "Data", "System").
/// </summary>
public class MenuGroup
{
public required string Id { get; set; }
public required string Label { get; set; }
public int SortOrder { get; set; }
public List<MenuItem> Items { get; set; } = new();
}

View file

@ -0,0 +1,15 @@
namespace CalendarServer.Features.Menu.Models;
/// <summary>
/// Represents a single menu item in the sidebar.
/// </summary>
public class MenuItem
{
public required string Id { get; set; }
public required string Label { get; set; }
public required string Icon { get; set; }
public required string Url { get; set; }
public UserRole MinimumRole { get; set; } = UserRole.Staff;
public int SortOrder { get; set; }
public bool IsActive { get; set; }
}

View file

@ -0,0 +1,11 @@
namespace CalendarServer.Features.Menu.Models;
/// <summary>
/// User roles for menu visibility. Higher value = more access.
/// </summary>
public enum UserRole
{
Staff = 0,
Manager = 1,
Admin = 2
}

View file

@ -0,0 +1,14 @@
using CalendarServer.Features.Menu.Models;
namespace CalendarServer.Features.Menu.Services;
/// <summary>
/// Service for retrieving menu structure based on user role.
/// </summary>
public interface IMenuService
{
/// <summary>
/// Get menu groups filtered by user role.
/// </summary>
List<MenuGroup> GetMenuForRole(UserRole role, string? currentUrl = null);
}

View file

@ -0,0 +1,187 @@
using CalendarServer.Features.Menu.Models;
using CalendarServer.Features.Language.Services;
namespace CalendarServer.Features.Menu.Services;
/// <summary>
/// Mock implementation of IMenuService with hardcoded menu data.
/// </summary>
public class MockMenuService : IMenuService
{
private readonly ILocalizationService _localize;
public MockMenuService(ILocalizationService localize)
{
_localize = localize;
}
public List<MenuGroup> GetMenuForRole(UserRole role, string? currentUrl = null)
{
var allGroups = GetAllMenuGroups();
return allGroups
.Select(g => new MenuGroup
{
Id = g.Id,
Label = _localize.Get($"groups.{g.Id}"),
SortOrder = g.SortOrder,
Items = g.Items
.Where(i => role >= i.MinimumRole)
.Select(i => new MenuItem
{
Id = i.Id,
Label = _localize.Get($"menu.{i.Id}"),
Icon = i.Icon,
Url = i.Url,
MinimumRole = i.MinimumRole,
SortOrder = i.SortOrder,
IsActive = currentUrl != null && i.Url.Equals(currentUrl, StringComparison.OrdinalIgnoreCase)
})
.OrderBy(i => i.SortOrder)
.ToList()
})
.Where(g => g.Items.Any())
.OrderBy(g => g.SortOrder)
.ToList();
}
private static List<MenuGroup> GetAllMenuGroups()
{
return new List<MenuGroup>
{
// DASHBOARD GROUP
new MenuGroup
{
Id = "dashboard",
Label = "Dashboard",
SortOrder = 1,
Items = new List<MenuItem>
{
new MenuItem
{
Id = "home",
Label = "Dashboard",
Icon = "ph-squares-four",
Url = "/",
MinimumRole = UserRole.Staff,
SortOrder = 1
},
new MenuItem
{
Id = "calendar",
Label = "Kalender",
Icon = "ph-calendar",
Url = "/poc-calendar.html",
MinimumRole = UserRole.Staff,
SortOrder = 2
},
new MenuItem
{
Id = "pos",
Label = "Kasse",
Icon = "ph-device-mobile",
Url = "/pos",
MinimumRole = UserRole.Staff,
SortOrder = 3
}
}
},
// DATA GROUP
new MenuGroup
{
Id = "data",
Label = "Data",
SortOrder = 2,
Items = new List<MenuItem>
{
new MenuItem
{
Id = "products",
Label = "Produkter & Lager",
Icon = "ph-package",
Url = "/poc-produkter.html",
MinimumRole = UserRole.Manager,
SortOrder = 1
},
new MenuItem
{
Id = "suppliers",
Label = "Leverandører",
Icon = "ph-truck",
Url = "/poc-leverandoerer.html",
MinimumRole = UserRole.Manager,
SortOrder = 2
},
new MenuItem
{
Id = "customers",
Label = "Kunder",
Icon = "ph-users",
Url = "/customers",
MinimumRole = UserRole.Staff,
SortOrder = 3
},
new MenuItem
{
Id = "employees",
Label = "Medarbejdere",
Icon = "ph-user",
Url = "/poc-medarbejdere.html",
MinimumRole = UserRole.Manager,
SortOrder = 4
}
}
},
// ANALYSE GROUP
new MenuGroup
{
Id = "analytics",
Label = "Analyse",
SortOrder = 3,
Items = new List<MenuItem>
{
new MenuItem
{
Id = "reports",
Label = "Statistik & Rapporter",
Icon = "ph-chart-bar",
Url = "/reports",
MinimumRole = UserRole.Manager,
SortOrder = 1
}
}
},
// SYSTEM GROUP
new MenuGroup
{
Id = "system",
Label = "System",
SortOrder = 4,
Items = new List<MenuItem>
{
new MenuItem
{
Id = "settings",
Label = "Indstillinger",
Icon = "ph-gear",
Url = "/poc-indstillinger.html",
MinimumRole = UserRole.Admin,
SortOrder = 1
},
new MenuItem
{
Id = "account",
Label = "Abonnement & Konto",
Icon = "ph-credit-card",
Url = "/poc-konto.html",
MinimumRole = UserRole.Admin,
SortOrder = 2
}
}
}
};
}
}

View file

@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Mvc;
using CalendarServer.Features.Menu.Models;
using CalendarServer.Features.Menu.Services;
namespace CalendarServer.Features.Menu;
/// <summary>
/// ViewComponent for rendering the side menu based on user role.
/// </summary>
public class SideMenuViewComponent : ViewComponent
{
private readonly IMenuService _menuService;
public SideMenuViewComponent(IMenuService menuService)
{
_menuService = menuService;
}
public IViewComponentResult Invoke(UserRole? role = null)
{
// Default to Admin for demo (in real app, get from auth)
var userRole = role ?? UserRole.Admin;
var currentUrl = HttpContext.Request.Path.Value;
var groups = _menuService.GetMenuForRole(userRole, currentUrl);
var viewModel = new SideMenuViewModel
{
Groups = groups,
CurrentUserRole = userRole
};
return View(viewModel);
}
}

View file

@ -0,0 +1,12 @@
using CalendarServer.Features.Menu.Models;
namespace CalendarServer.Features.Menu;
/// <summary>
/// ViewModel for the side menu partial view.
/// </summary>
public class SideMenuViewModel
{
public required List<MenuGroup> Groups { get; set; }
public UserRole CurrentUserRole { get; set; }
}