WIP
This commit is contained in:
parent
54b057886c
commit
7fc1ae0650
204 changed files with 4345 additions and 134 deletions
|
|
@ -6,6 +6,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||||
|
|
||||||
PlanTempus is a .NET 9 web application built with ASP.NET Core Razor Pages. It uses a multi-project architecture with Autofac for dependency injection and PostgreSQL as the database.
|
PlanTempus is a .NET 9 web application built with ASP.NET Core Razor Pages. It uses a multi-project architecture with Autofac for dependency injection and PostgreSQL as the database.
|
||||||
|
|
||||||
|
## Related Projects
|
||||||
|
|
||||||
|
- **calpoc** = Calendar POC projekt located at `../Calendar` (TypeScript calendar component with offline-first architecture, drag-and-drop, NovaDI, EventBus). When user mentions "calpoc", refer to this folder.
|
||||||
|
|
||||||
## Build and Development Commands
|
## Build and Development Commands
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
using Microsoft.ApplicationInsights;
|
|
||||||
using Microsoft.ApplicationInsights.Channel;
|
|
||||||
using Microsoft.ApplicationInsights.DataContracts;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
using PlanTempus.Core.Telemetry;
|
|
||||||
|
|
||||||
namespace PlanTempus.Core.SeqLogging
|
|
||||||
{
|
|
||||||
public class SeqBackgroundService : BackgroundService
|
|
||||||
{
|
|
||||||
private readonly IMessageChannel<ITelemetry> _messageChannel;
|
|
||||||
private readonly TelemetryClient _telemetryClient;
|
|
||||||
private readonly SeqLogger<SeqBackgroundService> _seqLogger;
|
|
||||||
|
|
||||||
public SeqBackgroundService(TelemetryClient telemetryClient,
|
|
||||||
IMessageChannel<ITelemetry> messageChannel,
|
|
||||||
SeqLogger<SeqBackgroundService> seqlogger)
|
|
||||||
{
|
|
||||||
_telemetryClient = telemetryClient;
|
|
||||||
_messageChannel = messageChannel;
|
|
||||||
_seqLogger = seqlogger;
|
|
||||||
|
|
||||||
_telemetryClient.TrackTrace("SeqBackgroundService started");
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
|
||||||
await foreach (var telemetry in _messageChannel.Reader.ReadAllAsync(stoppingToken))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
switch (telemetry)
|
|
||||||
{
|
|
||||||
case ExceptionTelemetry et:
|
|
||||||
await _seqLogger.LogAsync(et);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case TraceTelemetry et:
|
|
||||||
await _seqLogger.LogAsync(et);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case DependencyTelemetry et:
|
|
||||||
await _seqLogger.LogAsync(et);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case RequestTelemetry et:
|
|
||||||
await _seqLogger.LogAsync(et);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case EventTelemetry et:
|
|
||||||
await _seqLogger.LogAsync(et);
|
|
||||||
break;
|
|
||||||
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw new NotSupportedException(telemetry.GetType().Name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
//_telemetryClient.TrackException(ex); this is disabled for now, we need to think about the channel structure first
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
if (ex is not OperationCanceledException)
|
|
||||||
{
|
|
||||||
_telemetryClient.TrackException(ex);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
_telemetryClient.TrackTrace("StopAsync called: Service shutdown started");
|
|
||||||
_messageChannel.Dispose();
|
|
||||||
await base.StopAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
13
PTWork.code-workspace
Normal file
13
PTWork.code-workspace
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../Calendar"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"liveServer.settings.port": 5501
|
||||||
|
}
|
||||||
|
}
|
||||||
94
PlanTempus.Application/Features/Dashboard/Pages/Index.cshtml
Normal file
94
PlanTempus.Application/Features/Dashboard/Pages/Index.cshtml
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
@page "/"
|
||||||
|
@using PlanTempus.Application.Features.Dashboard.Pages
|
||||||
|
@model PlanTempus.Application.Features.Dashboard.Pages.IndexModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Dashboard";
|
||||||
|
}
|
||||||
|
|
||||||
|
<swp-page-container>
|
||||||
|
<!-- Stats Bar -->
|
||||||
|
<swp-stats-bar>
|
||||||
|
<swp-stat-card class="highlight">
|
||||||
|
<swp-stat-value>12</swp-stat-value>
|
||||||
|
<swp-stat-label>Bookinger i dag</swp-stat-label>
|
||||||
|
<swp-stat-trend class="up">
|
||||||
|
<i class="ph ph-check-circle"></i>
|
||||||
|
4 gennemført, 2 i gang
|
||||||
|
</swp-stat-trend>
|
||||||
|
</swp-stat-card>
|
||||||
|
<swp-stat-card class="success">
|
||||||
|
<swp-stat-value>8.450 kr</swp-stat-value>
|
||||||
|
<swp-stat-label>Forventet omsætning</swp-stat-label>
|
||||||
|
<swp-stat-trend class="up">
|
||||||
|
<i class="ph ph-trend-up"></i>
|
||||||
|
+12% vs. gennemsnit
|
||||||
|
</swp-stat-trend>
|
||||||
|
</swp-stat-card>
|
||||||
|
<swp-stat-card>
|
||||||
|
<swp-stat-value>78%</swp-stat-value>
|
||||||
|
<swp-stat-label>Belægningsgrad</swp-stat-label>
|
||||||
|
<swp-stat-trend class="up">
|
||||||
|
<i class="ph ph-trend-up"></i>
|
||||||
|
God kapacitet
|
||||||
|
</swp-stat-trend>
|
||||||
|
</swp-stat-card>
|
||||||
|
<swp-stat-card class="warning">
|
||||||
|
<swp-stat-value>4</swp-stat-value>
|
||||||
|
<swp-stat-label>Kræver opmærksomhed</swp-stat-label>
|
||||||
|
</swp-stat-card>
|
||||||
|
</swp-stats-bar>
|
||||||
|
|
||||||
|
<!-- Dashboard Content -->
|
||||||
|
<swp-dashboard-grid>
|
||||||
|
<swp-main-column>
|
||||||
|
<!-- AI Insight -->
|
||||||
|
<swp-card>
|
||||||
|
<swp-ai-insight>
|
||||||
|
<swp-ai-header>
|
||||||
|
<i class="ph ph-sparkle"></i>
|
||||||
|
<span>AI Analyse</span>
|
||||||
|
</swp-ai-header>
|
||||||
|
<swp-ai-text>
|
||||||
|
<strong>Godt i gang!</strong> 4 af 12 bookinger er gennemført. 2 er i gang nu, og 6 venter.
|
||||||
|
Forventet omsætning: <strong>8.450 kr</strong> – allerede realiseret <strong>2.150 kr</strong>.
|
||||||
|
</swp-ai-text>
|
||||||
|
</swp-ai-insight>
|
||||||
|
</swp-card>
|
||||||
|
|
||||||
|
<!-- Today's Bookings Preview -->
|
||||||
|
<swp-card>
|
||||||
|
<swp-card-header>
|
||||||
|
<swp-card-title>
|
||||||
|
<i class="ph ph-calendar-check"></i>
|
||||||
|
Dagens bookinger
|
||||||
|
</swp-card-title>
|
||||||
|
<swp-card-action>Se alle</swp-card-action>
|
||||||
|
</swp-card-header>
|
||||||
|
<swp-card-content>
|
||||||
|
<p>Booking oversigt kommer her...</p>
|
||||||
|
</swp-card-content>
|
||||||
|
</swp-card>
|
||||||
|
</swp-main-column>
|
||||||
|
|
||||||
|
<swp-side-column>
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<swp-card>
|
||||||
|
<swp-card-header>
|
||||||
|
<swp-card-title>Hurtige handlinger</swp-card-title>
|
||||||
|
</swp-card-header>
|
||||||
|
<swp-card-content>
|
||||||
|
<swp-quick-actions>
|
||||||
|
<swp-quick-action-btn>
|
||||||
|
<i class="ph ph-plus"></i>
|
||||||
|
Ny booking
|
||||||
|
</swp-quick-action-btn>
|
||||||
|
<swp-quick-action-btn>
|
||||||
|
<i class="ph ph-user-plus"></i>
|
||||||
|
Ny kunde
|
||||||
|
</swp-quick-action-btn>
|
||||||
|
</swp-quick-actions>
|
||||||
|
</swp-card-content>
|
||||||
|
</swp-card>
|
||||||
|
</swp-side-column>
|
||||||
|
</swp-dashboard-grid>
|
||||||
|
</swp-page-container>
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace PlanTempus.Application.Features.Dashboard.Pages;
|
||||||
|
|
||||||
|
public class IndexModel : PageModel
|
||||||
|
{
|
||||||
|
public void OnGet()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
namespace PlanTempus.Application.Features.Localization.Models;
|
||||||
|
|
||||||
|
public class SupportedCulture
|
||||||
|
{
|
||||||
|
public required string Code { get; set; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public required string NativeName { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
using PlanTempus.Application.Features.Localization.Models;
|
||||||
|
|
||||||
|
namespace PlanTempus.Application.Features.Localization.Services;
|
||||||
|
|
||||||
|
public interface ILocalizationService
|
||||||
|
{
|
||||||
|
string Get(string key, string? culture = null);
|
||||||
|
string CurrentCulture { get; }
|
||||||
|
IEnumerable<SupportedCulture> GetSupportedCultures();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using PlanTempus.Application.Features.Localization.Models;
|
||||||
|
|
||||||
|
namespace PlanTempus.Application.Features.Localization.Services;
|
||||||
|
|
||||||
|
public class JsonLocalizationService : ILocalizationService
|
||||||
|
{
|
||||||
|
private readonly string _translationsPath;
|
||||||
|
|
||||||
|
public JsonLocalizationService(IWebHostEnvironment env)
|
||||||
|
{
|
||||||
|
_translationsPath = Path.Combine(env.ContentRootPath, "Features", "Localization", "Translations");
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CurrentCulture => "da";
|
||||||
|
|
||||||
|
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" }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
PlanTempus.Application/Features/Menu/Models/MenuGroup.cs
Normal file
12
PlanTempus.Application/Features/Menu/Models/MenuGroup.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
namespace PlanTempus.Application.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();
|
||||||
|
}
|
||||||
15
PlanTempus.Application/Features/Menu/Models/MenuItem.cs
Normal file
15
PlanTempus.Application/Features/Menu/Models/MenuItem.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
namespace PlanTempus.Application.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; }
|
||||||
|
}
|
||||||
11
PlanTempus.Application/Features/Menu/Models/UserRole.cs
Normal file
11
PlanTempus.Application/Features/Menu/Models/UserRole.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
namespace PlanTempus.Application.Features.Menu.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User roles for menu visibility. Higher value = more access.
|
||||||
|
/// </summary>
|
||||||
|
public enum UserRole
|
||||||
|
{
|
||||||
|
Staff = 0,
|
||||||
|
Manager = 1,
|
||||||
|
Admin = 2
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
using PlanTempus.Application.Features.Menu.Models;
|
||||||
|
|
||||||
|
namespace PlanTempus.Application.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);
|
||||||
|
}
|
||||||
187
PlanTempus.Application/Features/Menu/Services/MockMenuService.cs
Normal file
187
PlanTempus.Application/Features/Menu/Services/MockMenuService.cs
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
using PlanTempus.Application.Features.Localization.Services;
|
||||||
|
using PlanTempus.Application.Features.Menu.Models;
|
||||||
|
|
||||||
|
namespace PlanTempus.Application.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using PlanTempus.Application.Features.Menu.Services;
|
||||||
|
using PlanTempus.Application.Features.Menu;
|
||||||
|
using PlanTempus.Application.Features.Menu.Models;
|
||||||
|
|
||||||
|
namespace PlanTempus.Application.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
PlanTempus.Application/Features/Menu/SideMenuViewModel.cs
Normal file
12
PlanTempus.Application/Features/Menu/SideMenuViewModel.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
using PlanTempus.Application.Features.Menu.Models;
|
||||||
|
|
||||||
|
namespace PlanTempus.Application.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; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
@using PlanTempus.Application.Features.Menu
|
||||||
|
@model SideMenuViewModel
|
||||||
|
|
||||||
|
<swp-side-menu>
|
||||||
|
<swp-side-menu-header>
|
||||||
|
<i class="ph ph-squares-four"></i>
|
||||||
|
<swp-side-menu-logo localize="sidebar.appName">Salon OS</swp-side-menu-logo>
|
||||||
|
<swp-menu-toggle id="menuToggle">
|
||||||
|
<i class="ph ph-caret-left"></i>
|
||||||
|
</swp-menu-toggle>
|
||||||
|
</swp-side-menu-header>
|
||||||
|
|
||||||
|
<swp-side-menu-nav>
|
||||||
|
@foreach (var group in Model.Groups)
|
||||||
|
{
|
||||||
|
<swp-side-menu-group>
|
||||||
|
<swp-side-menu-label>@group.Label</swp-side-menu-label>
|
||||||
|
@foreach (var item in group.Items)
|
||||||
|
{
|
||||||
|
<a href="@item.Url" is="swp-side-menu-item"
|
||||||
|
data-active="@(item.IsActive ? "true" : "false")"
|
||||||
|
data-tooltip="@item.Label">
|
||||||
|
<i class="ph @item.Icon"></i>
|
||||||
|
<span>@item.Label</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</swp-side-menu-group>
|
||||||
|
}
|
||||||
|
</swp-side-menu-nav>
|
||||||
|
|
||||||
|
<swp-side-menu-footer>
|
||||||
|
<swp-side-menu-action class="lock" id="lockScreen" title="Lås skærm">
|
||||||
|
<i class="ph ph-lock"></i>
|
||||||
|
<span localize="sidebar.lockScreen">Lås skærm</span>
|
||||||
|
</swp-side-menu-action>
|
||||||
|
</swp-side-menu-footer>
|
||||||
|
</swp-side-menu>
|
||||||
|
|
||||||
|
<!-- Tooltip for collapsed menu -->
|
||||||
|
<span id="menuTooltip" class="swp-menu-tooltip" popover="manual"></span>
|
||||||
49
PlanTempus.Application/Features/Shared/_ProfileDrawer.cshtml
Normal file
49
PlanTempus.Application/Features/Shared/_ProfileDrawer.cshtml
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<swp-profile-drawer id="profileDrawer">
|
||||||
|
<swp-drawer-header>
|
||||||
|
<swp-drawer-title>Profil</swp-drawer-title>
|
||||||
|
<swp-drawer-close id="closeProfileDrawer">
|
||||||
|
<i class="ph ph-x"></i>
|
||||||
|
</swp-drawer-close>
|
||||||
|
</swp-drawer-header>
|
||||||
|
|
||||||
|
<swp-drawer-content>
|
||||||
|
<swp-profile-section>
|
||||||
|
<swp-profile-avatar-large>MJ</swp-profile-avatar-large>
|
||||||
|
<swp-profile-name-large>Maria Jensen</swp-profile-name-large>
|
||||||
|
<swp-profile-email>maria@salon.dk</swp-profile-email>
|
||||||
|
</swp-profile-section>
|
||||||
|
|
||||||
|
<swp-drawer-divider></swp-drawer-divider>
|
||||||
|
|
||||||
|
<swp-drawer-menu>
|
||||||
|
<swp-drawer-menu-item>
|
||||||
|
<i class="ph ph-user"></i>
|
||||||
|
<span>Min profil</span>
|
||||||
|
</swp-drawer-menu-item>
|
||||||
|
<swp-drawer-menu-item>
|
||||||
|
<i class="ph ph-gear"></i>
|
||||||
|
<span>Indstillinger</span>
|
||||||
|
</swp-drawer-menu-item>
|
||||||
|
</swp-drawer-menu>
|
||||||
|
|
||||||
|
<swp-drawer-divider></swp-drawer-divider>
|
||||||
|
|
||||||
|
<swp-theme-toggle>
|
||||||
|
<swp-theme-label>
|
||||||
|
<i class="ph ph-moon"></i>
|
||||||
|
<span>Mørk tilstand</span>
|
||||||
|
</swp-theme-label>
|
||||||
|
<swp-toggle-switch id="themeToggle">
|
||||||
|
<input type="checkbox" id="themeCheckbox">
|
||||||
|
<swp-toggle-slider></swp-toggle-slider>
|
||||||
|
</swp-toggle-switch>
|
||||||
|
</swp-theme-toggle>
|
||||||
|
</swp-drawer-content>
|
||||||
|
|
||||||
|
<swp-drawer-footer>
|
||||||
|
<swp-drawer-action class="logout" id="logoutBtn">
|
||||||
|
<i class="ph ph-sign-out"></i>
|
||||||
|
<span>Log ud</span>
|
||||||
|
</swp-drawer-action>
|
||||||
|
</swp-drawer-footer>
|
||||||
|
</swp-profile-drawer>
|
||||||
26
PlanTempus.Application/Features/Shared/_TopBar.cshtml
Normal file
26
PlanTempus.Application/Features/Shared/_TopBar.cshtml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<swp-app-topbar>
|
||||||
|
<swp-topbar-search>
|
||||||
|
<i class="ph ph-magnifying-glass"></i>
|
||||||
|
<input type="text" placeholder="Søg i Salon OS..." id="globalSearch">
|
||||||
|
<kbd>⌘K</kbd>
|
||||||
|
</swp-topbar-search>
|
||||||
|
|
||||||
|
<swp-topbar-actions>
|
||||||
|
<!-- Notifications -->
|
||||||
|
<swp-topbar-btn id="notificationsBtn" title="Notifikationer">
|
||||||
|
<i class="ph ph-bell"></i>
|
||||||
|
<swp-notification-badge>3</swp-notification-badge>
|
||||||
|
</swp-topbar-btn>
|
||||||
|
|
||||||
|
<swp-topbar-divider></swp-topbar-divider>
|
||||||
|
|
||||||
|
<!-- Profile (opens drawer) -->
|
||||||
|
<swp-topbar-profile id="profileTrigger">
|
||||||
|
<swp-profile-avatar>MJ</swp-profile-avatar>
|
||||||
|
<swp-profile-info>
|
||||||
|
<swp-profile-name>Maria Jensen</swp-profile-name>
|
||||||
|
<swp-profile-role>Administrator</swp-profile-role>
|
||||||
|
</swp-profile-info>
|
||||||
|
</swp-topbar-profile>
|
||||||
|
</swp-topbar-actions>
|
||||||
|
</swp-app-topbar>
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
@model PlanTempus.Application.Features.Menu.SideMenuViewModel
|
||||||
|
|
||||||
|
<swp-side-menu>
|
||||||
|
<swp-side-menu-header>
|
||||||
|
<i class="ph ph-squares-four"></i>
|
||||||
|
<swp-side-menu-logo localize="sidebar.appName">Salon OS</swp-side-menu-logo>
|
||||||
|
<swp-menu-toggle id="menuToggle">
|
||||||
|
<i class="ph ph-caret-left"></i>
|
||||||
|
</swp-menu-toggle>
|
||||||
|
</swp-side-menu-header>
|
||||||
|
|
||||||
|
<swp-side-menu-nav>
|
||||||
|
@foreach (var group in Model.Groups)
|
||||||
|
{
|
||||||
|
<swp-side-menu-group>
|
||||||
|
<swp-side-menu-label>@group.Label</swp-side-menu-label>
|
||||||
|
@foreach (var item in group.Items)
|
||||||
|
{
|
||||||
|
<a href="@item.Url" is="swp-side-menu-item"
|
||||||
|
data-active="@(item.IsActive ? "true" : "false")"
|
||||||
|
data-tooltip="@item.Label">
|
||||||
|
<i class="ph @item.Icon"></i>
|
||||||
|
<span>@item.Label</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</swp-side-menu-group>
|
||||||
|
}
|
||||||
|
</swp-side-menu-nav>
|
||||||
|
|
||||||
|
<swp-side-menu-footer>
|
||||||
|
<swp-side-menu-action class="lock" id="lockScreen" title="Lås skærm">
|
||||||
|
<i class="ph ph-lock"></i>
|
||||||
|
<span localize="sidebar.lockScreen">Lås skærm</span>
|
||||||
|
</swp-side-menu-action>
|
||||||
|
</swp-side-menu-footer>
|
||||||
|
</swp-side-menu>
|
||||||
|
|
||||||
|
<!-- Tooltip for collapsed menu -->
|
||||||
|
<span id="menuTooltip" class="swp-menu-tooltip" popover="manual"></span>
|
||||||
38
PlanTempus.Application/Features/_Shared/Pages/_Layout.cshtml
Normal file
38
PlanTempus.Application/Features/_Shared/Pages/_Layout.cshtml
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="da">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>@ViewData["Title"] - Salon OS</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@@phosphor-icons/web@@2.1.2/src/regular/style.css" />
|
||||||
|
<!-- Design System -->
|
||||||
|
<link rel="stylesheet" href="~/css/design-system.css">
|
||||||
|
<link rel="stylesheet" href="~/css/base.css">
|
||||||
|
<!-- Layout Components -->
|
||||||
|
<link rel="stylesheet" href="~/css/app-layout.css">
|
||||||
|
<link rel="stylesheet" href="~/css/sidebar.css">
|
||||||
|
<link rel="stylesheet" href="~/css/topbar.css">
|
||||||
|
<link rel="stylesheet" href="~/css/drawers.css">
|
||||||
|
<!-- Page Components -->
|
||||||
|
<link rel="stylesheet" href="~/css/page.css">
|
||||||
|
<link rel="stylesheet" href="~/css/stats.css">
|
||||||
|
@await RenderSectionAsync("Styles", required: false)
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<swp-app-layout id="appLayout">
|
||||||
|
@await Component.InvokeAsync("SideMenu")
|
||||||
|
<partial name="_TopBar" />
|
||||||
|
|
||||||
|
<swp-main-content>
|
||||||
|
@RenderBody()
|
||||||
|
</swp-main-content>
|
||||||
|
</swp-app-layout>
|
||||||
|
|
||||||
|
<partial name="_ProfileDrawer" />
|
||||||
|
<swp-drawer-overlay id="drawerOverlay"></swp-drawer-overlay>
|
||||||
|
|
||||||
|
<script type="module" src="~/js/app.js"></script>
|
||||||
|
@await RenderSectionAsync("Scripts", required: false)
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
<swp-profile-drawer id="profileDrawer">
|
||||||
|
<swp-drawer-header>
|
||||||
|
<swp-drawer-title>Profil</swp-drawer-title>
|
||||||
|
<swp-drawer-close id="closeProfileDrawer">
|
||||||
|
<i class="ph ph-x"></i>
|
||||||
|
</swp-drawer-close>
|
||||||
|
</swp-drawer-header>
|
||||||
|
|
||||||
|
<swp-drawer-content>
|
||||||
|
<swp-profile-section>
|
||||||
|
<swp-profile-avatar-large>MJ</swp-profile-avatar-large>
|
||||||
|
<swp-profile-name-large>Maria Jensen</swp-profile-name-large>
|
||||||
|
<swp-profile-email>maria@salon.dk</swp-profile-email>
|
||||||
|
</swp-profile-section>
|
||||||
|
|
||||||
|
<swp-drawer-divider></swp-drawer-divider>
|
||||||
|
|
||||||
|
<swp-drawer-menu>
|
||||||
|
<swp-drawer-menu-item>
|
||||||
|
<i class="ph ph-user"></i>
|
||||||
|
<span>Min profil</span>
|
||||||
|
</swp-drawer-menu-item>
|
||||||
|
<swp-drawer-menu-item>
|
||||||
|
<i class="ph ph-gear"></i>
|
||||||
|
<span>Indstillinger</span>
|
||||||
|
</swp-drawer-menu-item>
|
||||||
|
</swp-drawer-menu>
|
||||||
|
|
||||||
|
<swp-drawer-divider></swp-drawer-divider>
|
||||||
|
|
||||||
|
<swp-theme-toggle>
|
||||||
|
<swp-theme-label>
|
||||||
|
<i class="ph ph-moon"></i>
|
||||||
|
<span>Mørk tilstand</span>
|
||||||
|
</swp-theme-label>
|
||||||
|
<swp-toggle-switch id="themeToggle">
|
||||||
|
<input type="checkbox" id="themeCheckbox">
|
||||||
|
<swp-toggle-slider></swp-toggle-slider>
|
||||||
|
</swp-toggle-switch>
|
||||||
|
</swp-theme-toggle>
|
||||||
|
</swp-drawer-content>
|
||||||
|
|
||||||
|
<swp-drawer-footer>
|
||||||
|
<swp-drawer-action class="logout" id="logoutBtn">
|
||||||
|
<i class="ph ph-sign-out"></i>
|
||||||
|
<span>Log ud</span>
|
||||||
|
</swp-drawer-action>
|
||||||
|
</swp-drawer-footer>
|
||||||
|
</swp-profile-drawer>
|
||||||
26
PlanTempus.Application/Features/_Shared/Pages/_TopBar.cshtml
Normal file
26
PlanTempus.Application/Features/_Shared/Pages/_TopBar.cshtml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<swp-app-topbar>
|
||||||
|
<swp-topbar-search>
|
||||||
|
<i class="ph ph-magnifying-glass"></i>
|
||||||
|
<input type="text" placeholder="Søg i Salon OS..." id="globalSearch">
|
||||||
|
<kbd>⌘K</kbd>
|
||||||
|
</swp-topbar-search>
|
||||||
|
|
||||||
|
<swp-topbar-actions>
|
||||||
|
<!-- Notifications -->
|
||||||
|
<swp-topbar-btn id="notificationsBtn" title="Notifikationer">
|
||||||
|
<i class="ph ph-bell"></i>
|
||||||
|
<swp-notification-badge>3</swp-notification-badge>
|
||||||
|
</swp-topbar-btn>
|
||||||
|
|
||||||
|
<swp-topbar-divider></swp-topbar-divider>
|
||||||
|
|
||||||
|
<!-- Profile (opens drawer) -->
|
||||||
|
<swp-topbar-profile id="profileTrigger">
|
||||||
|
<swp-profile-avatar>MJ</swp-profile-avatar>
|
||||||
|
<swp-profile-info>
|
||||||
|
<swp-profile-name>Maria Jensen</swp-profile-name>
|
||||||
|
<swp-profile-role>Administrator</swp-profile-role>
|
||||||
|
</swp-profile-info>
|
||||||
|
</swp-topbar-profile>
|
||||||
|
</swp-topbar-actions>
|
||||||
|
</swp-app-topbar>
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
@using PlanTempus.Application
|
||||||
|
@namespace PlanTempus.Application.Features
|
||||||
|
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
|
@addTagHelper *, PlanTempus.Application
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
@{
|
||||||
|
Layout = "/Features/_Shared/Pages/_Layout.cshtml";
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
using Microsoft.AspNetCore.Razor.TagHelpers;
|
||||||
|
using PlanTempus.Application.Features.Localization.Services;
|
||||||
|
|
||||||
|
namespace PlanTempus.Application.Features._Shared.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");
|
||||||
|
}
|
||||||
|
}
|
||||||
4
PlanTempus.Application/Features/_ViewImports.cshtml
Normal file
4
PlanTempus.Application/Features/_ViewImports.cshtml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
@using PlanTempus.Application
|
||||||
|
@namespace PlanTempus.Application.Features
|
||||||
|
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
|
@addTagHelper *, PlanTempus.Application
|
||||||
3
PlanTempus.Application/Features/_ViewStart.cshtml
Normal file
3
PlanTempus.Application/Features/_ViewStart.cshtml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
@{
|
||||||
|
Layout = "/Features/_Shared/Pages/_Layout.cshtml";
|
||||||
|
}
|
||||||
19
PlanTempus.Application/PlanTempus.Application.csproj
Normal file
19
PlanTempus.Application/PlanTempus.Application.csproj
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Autofac.Extensions.DependencyInjection" Version="10.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\PlanTempus.Core\PlanTempus.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\PlanTempus.Database\PlanTempus.Database.csproj" />
|
||||||
|
<ProjectReference Include="..\PlanTempus.Components\PlanTempus.Components.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
47
PlanTempus.Application/Program.cs
Normal file
47
PlanTempus.Application/Program.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Razor;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using PlanTempus.Application.Features.Localization.Services;
|
||||||
|
using PlanTempus.Application.Features.Menu.Services;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Add Razor Pages with feature-based structure
|
||||||
|
builder.Services.AddRazorPages(options =>
|
||||||
|
{
|
||||||
|
options.RootDirectory = "/Features";
|
||||||
|
})
|
||||||
|
.AddRazorOptions(options =>
|
||||||
|
{
|
||||||
|
// View locations for partials and ViewComponents
|
||||||
|
options.ViewLocationFormats.Add("/Features/_Shared/Pages/{0}.cshtml");
|
||||||
|
options.ViewLocationFormats.Add("/Features/_Shared/Components/{1}/{0}.cshtml");
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register application services
|
||||||
|
builder.Services.AddScoped<IMenuService, MockMenuService>();
|
||||||
|
builder.Services.AddScoped<ILocalizationService, JsonLocalizationService>();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Developer exception page for debugging
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseDeveloperExceptionPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve static files from wwwroot
|
||||||
|
app.UseStaticFiles();
|
||||||
|
|
||||||
|
// Configure routing
|
||||||
|
app.UseRouting();
|
||||||
|
|
||||||
|
// Map Razor Pages
|
||||||
|
app.MapRazorPages();
|
||||||
|
|
||||||
|
app.Run("http://localhost:8000");
|
||||||
|
|
||||||
|
// Note: Set ASPNETCORE_ENVIRONMENT=Development for detailed error pages
|
||||||
12
PlanTempus.Application/Properties/launchSettings.json
Normal file
12
PlanTempus.Application/Properties/launchSettings.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"PlanTempus.Application": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"launchBrowser": true,
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
},
|
||||||
|
"applicationUrl": "https://localhost:55726;http://localhost:55727"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
PlanTempus.Application/build.js
Normal file
24
PlanTempus.Application/build.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import * as esbuild from 'esbuild';
|
||||||
|
|
||||||
|
async function build() {
|
||||||
|
try {
|
||||||
|
await esbuild.build({
|
||||||
|
entryPoints: ['wwwroot/ts/app.ts'],
|
||||||
|
bundle: true,
|
||||||
|
outfile: 'wwwroot/js/app.js',
|
||||||
|
format: 'esm',
|
||||||
|
sourcemap: 'inline',
|
||||||
|
target: 'es2020',
|
||||||
|
minify: false,
|
||||||
|
keepNames: true,
|
||||||
|
platform: 'browser'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('App bundle created: wwwroot/js/app.js');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Build failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
build();
|
||||||
50
PlanTempus.Application/wwwroot/css/app-layout.css
Normal file
50
PlanTempus.Application/wwwroot/css/app-layout.css
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
/**
|
||||||
|
* App Layout - Main Grid Structure
|
||||||
|
*
|
||||||
|
* Definerer den overordnede app-struktur med sidebar og main content
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
MAIN APP GRID
|
||||||
|
=========================================== */
|
||||||
|
swp-app-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--side-menu-width) 1fr;
|
||||||
|
grid-template-rows: var(--topbar-height) 1fr;
|
||||||
|
height: 100vh;
|
||||||
|
transition: grid-template-columns var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
COLLAPSED MENU STATE
|
||||||
|
=========================================== */
|
||||||
|
swp-app-layout.menu-collapsed {
|
||||||
|
grid-template-columns: var(--side-menu-width-collapsed) 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
MAIN CONTENT AREA
|
||||||
|
=========================================== */
|
||||||
|
swp-main-content {
|
||||||
|
display: block;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
DRAWER OVERLAY
|
||||||
|
=========================================== */
|
||||||
|
swp-drawer-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: var(--z-overlay);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity var(--transition-normal), visibility var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-drawer-overlay.active {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
118
PlanTempus.Application/wwwroot/css/base.css
Normal file
118
PlanTempus.Application/wwwroot/css/base.css
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
/**
|
||||||
|
* Base Styles - Reset & Global Elements
|
||||||
|
*
|
||||||
|
* Normalization og grundlæggende styling
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
FONT FACES
|
||||||
|
=========================================== */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
src: url('../fonts/Poppins-Regular.woff') format('woff');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
src: url('../fonts/Poppins-Medium.woff') format('woff');
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
src: url('../fonts/Poppins-SemiBold.woff') format('woff');
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Poppins';
|
||||||
|
src: url('../fonts/Poppins-Bold.woff') format('woff');
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
RESET
|
||||||
|
=========================================== */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
BASE ELEMENTS
|
||||||
|
=========================================== */
|
||||||
|
body {
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-background);
|
||||||
|
line-height: var(--line-height-normal);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
a {
|
||||||
|
color: var(--color-teal);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lists */
|
||||||
|
ul, ol {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Images */
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inputs */
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus visible */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-teal);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection */
|
||||||
|
::selection {
|
||||||
|
background: var(--color-teal-light);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
163
PlanTempus.Application/wwwroot/css/design-system.css
Normal file
163
PlanTempus.Application/wwwroot/css/design-system.css
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
/**
|
||||||
|
* SWP Design System - CSS Variables
|
||||||
|
*
|
||||||
|
* Dette er den centrale definition af alle design tokens.
|
||||||
|
* Alle farver, fonts og layout-variabler defineres her.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
COLOR PALETTE - Light Mode (Default)
|
||||||
|
=========================================== */
|
||||||
|
:root {
|
||||||
|
/* Surfaces */
|
||||||
|
--color-surface: #fff;
|
||||||
|
--color-background: #f5f5f5;
|
||||||
|
--color-background-hover: #f0f0f0;
|
||||||
|
--color-background-alt: #fafafa;
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--color-border: #e0e0e0;
|
||||||
|
--color-border-light: #f0f0f0;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--color-text: #333;
|
||||||
|
--color-text-secondary: #666;
|
||||||
|
--color-text-muted: #999;
|
||||||
|
|
||||||
|
/* Brand Colors */
|
||||||
|
--color-teal: #00897b;
|
||||||
|
--color-teal-light: color-mix(in srgb, var(--color-teal) 10%, transparent);
|
||||||
|
|
||||||
|
/* Semantic Colors */
|
||||||
|
--color-blue: #1976d2;
|
||||||
|
--color-green: #43a047;
|
||||||
|
--color-amber: #f59e0b;
|
||||||
|
--color-red: #e53935;
|
||||||
|
--color-purple: #8b5cf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
COLOR PALETTE - Dark Mode (System)
|
||||||
|
=========================================== */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not(.light-mode) {
|
||||||
|
--color-surface: #1e1e1e;
|
||||||
|
--color-background: #121212;
|
||||||
|
--color-background-hover: #2a2a2a;
|
||||||
|
--color-background-alt: #1a1a1a;
|
||||||
|
|
||||||
|
--color-border: #333;
|
||||||
|
--color-border-light: #2a2a2a;
|
||||||
|
|
||||||
|
--color-text: #e0e0e0;
|
||||||
|
--color-text-secondary: #999;
|
||||||
|
--color-text-muted: #666;
|
||||||
|
|
||||||
|
--color-teal: #26a69a;
|
||||||
|
--color-blue: #42a5f5;
|
||||||
|
--color-green: #66bb6a;
|
||||||
|
--color-amber: #ffb74d;
|
||||||
|
--color-red: #ef5350;
|
||||||
|
--color-purple: #a78bfa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
COLOR PALETTE - Dark Mode (Manual)
|
||||||
|
=========================================== */
|
||||||
|
:root.dark-mode {
|
||||||
|
--color-surface: #1e1e1e;
|
||||||
|
--color-background: #121212;
|
||||||
|
--color-background-hover: #2a2a2a;
|
||||||
|
--color-background-alt: #1a1a1a;
|
||||||
|
|
||||||
|
--color-border: #333;
|
||||||
|
--color-border-light: #2a2a2a;
|
||||||
|
|
||||||
|
--color-text: #e0e0e0;
|
||||||
|
--color-text-secondary: #999;
|
||||||
|
--color-text-muted: #666;
|
||||||
|
|
||||||
|
--color-teal: #26a69a;
|
||||||
|
--color-blue: #42a5f5;
|
||||||
|
--color-green: #66bb6a;
|
||||||
|
--color-amber: #ffb74d;
|
||||||
|
--color-red: #ef5350;
|
||||||
|
--color-purple: #a78bfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
TYPOGRAPHY
|
||||||
|
=========================================== */
|
||||||
|
:root {
|
||||||
|
--font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
|
||||||
|
/* Font Sizes */
|
||||||
|
--font-size-xs: 11px;
|
||||||
|
--font-size-sm: 12px;
|
||||||
|
--font-size-base: 14px;
|
||||||
|
--font-size-md: 13px;
|
||||||
|
--font-size-lg: 16px;
|
||||||
|
--font-size-xl: 22px;
|
||||||
|
|
||||||
|
/* Line Heights */
|
||||||
|
--line-height-tight: 1.25;
|
||||||
|
--line-height-normal: 1.5;
|
||||||
|
--line-height-relaxed: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
SPACING
|
||||||
|
=========================================== */
|
||||||
|
:root {
|
||||||
|
--spacing-1: 4px;
|
||||||
|
--spacing-2: 8px;
|
||||||
|
--spacing-3: 12px;
|
||||||
|
--spacing-4: 16px;
|
||||||
|
--spacing-5: 20px;
|
||||||
|
--spacing-6: 24px;
|
||||||
|
--spacing-8: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
LAYOUT
|
||||||
|
=========================================== */
|
||||||
|
:root {
|
||||||
|
--side-menu-width: 240px;
|
||||||
|
--side-menu-width-collapsed: 64px;
|
||||||
|
--topbar-height: 56px;
|
||||||
|
--page-max-width: 1400px;
|
||||||
|
--border-radius: 6px;
|
||||||
|
--border-radius-lg: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
TRANSITIONS
|
||||||
|
=========================================== */
|
||||||
|
:root {
|
||||||
|
--transition-fast: 150ms ease;
|
||||||
|
--transition-normal: 200ms ease;
|
||||||
|
--transition-slow: 300ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
Z-INDEX LAYERS
|
||||||
|
=========================================== */
|
||||||
|
:root {
|
||||||
|
--z-dropdown: 100;
|
||||||
|
--z-sticky: 200;
|
||||||
|
--z-overlay: 900;
|
||||||
|
--z-drawer: 1000;
|
||||||
|
--z-modal: 1100;
|
||||||
|
--z-tooltip: 1200;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
SHADOWS
|
||||||
|
=========================================== */
|
||||||
|
:root {
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
258
PlanTempus.Application/wwwroot/css/drawers.css
Normal file
258
PlanTempus.Application/wwwroot/css/drawers.css
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
/**
|
||||||
|
* Drawers - Slide-in Panels
|
||||||
|
*
|
||||||
|
* Profile drawer, notifications drawer, etc.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
BASE DRAWER
|
||||||
|
=========================================== */
|
||||||
|
swp-profile-drawer,
|
||||||
|
swp-notification-drawer,
|
||||||
|
swp-todo-drawer {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 320px;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
z-index: var(--z-drawer);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-profile-drawer.active,
|
||||||
|
swp-notification-drawer.active,
|
||||||
|
swp-todo-drawer.active {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
DRAWER HEADER
|
||||||
|
=========================================== */
|
||||||
|
swp-drawer-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--spacing-4) var(--spacing-5);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-drawer-title {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-drawer-close {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-drawer-close:hover {
|
||||||
|
background: var(--color-background-hover);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-drawer-close i {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
DRAWER CONTENT
|
||||||
|
=========================================== */
|
||||||
|
swp-drawer-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--spacing-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-drawer-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--color-border);
|
||||||
|
margin: var(--spacing-4) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
PROFILE SECTION
|
||||||
|
=========================================== */
|
||||||
|
swp-profile-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--spacing-4) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-profile-avatar-large {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-teal);
|
||||||
|
color: white;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: var(--spacing-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-profile-name-large {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin-bottom: var(--spacing-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-profile-email {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
DRAWER MENU
|
||||||
|
=========================================== */
|
||||||
|
swp-drawer-menu {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-drawer-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-3);
|
||||||
|
padding: var(--spacing-3) var(--spacing-3);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-drawer-menu-item:hover {
|
||||||
|
background: var(--color-background-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-drawer-menu-item i {
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
THEME TOGGLE
|
||||||
|
=========================================== */
|
||||||
|
swp-theme-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--spacing-3);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background: var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-theme-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-3);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-theme-label i {
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-toggle-switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-toggle-slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-toggle-slider::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-toggle-switch input:checked + swp-toggle-slider {
|
||||||
|
background: var(--color-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-toggle-switch input:checked + swp-toggle-slider::before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
DRAWER FOOTER
|
||||||
|
=========================================== */
|
||||||
|
swp-drawer-footer {
|
||||||
|
padding: var(--spacing-4) var(--spacing-5);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-drawer-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--spacing-3);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
font-family: var(--font-family);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-drawer-action:hover {
|
||||||
|
background: var(--color-background-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-drawer-action.logout:hover {
|
||||||
|
color: var(--color-red);
|
||||||
|
border-color: var(--color-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-drawer-action i {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
204
PlanTempus.Application/wwwroot/css/page.css
Normal file
204
PlanTempus.Application/wwwroot/css/page.css
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
/**
|
||||||
|
* Page Layout - Content Area Structure
|
||||||
|
*
|
||||||
|
* Page container, headers, cards og grid layouts
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
PAGE CONTAINER
|
||||||
|
=========================================== */
|
||||||
|
swp-page-container {
|
||||||
|
display: block;
|
||||||
|
max-width: var(--page-max-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--spacing-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
PAGE HEADER
|
||||||
|
=========================================== */
|
||||||
|
swp-page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--spacing-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-page-title h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin-bottom: var(--spacing-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-page-title p {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-page-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
CARDS
|
||||||
|
=========================================== */
|
||||||
|
swp-card {
|
||||||
|
display: block;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
margin-bottom: var(--spacing-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--spacing-4) var(--spacing-5);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-card-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-card-title i {
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-card-action {
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
color: var(--color-teal);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-card-action:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-card-content {
|
||||||
|
padding: var(--spacing-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
DASHBOARD GRID
|
||||||
|
=========================================== */
|
||||||
|
swp-dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 350px;
|
||||||
|
gap: var(--spacing-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-main-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-side-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
AI INSIGHT
|
||||||
|
=========================================== */
|
||||||
|
swp-ai-insight {
|
||||||
|
display: block;
|
||||||
|
padding: var(--spacing-4) var(--spacing-5);
|
||||||
|
background: linear-gradient(135deg,
|
||||||
|
color-mix(in srgb, var(--color-purple) 8%, transparent),
|
||||||
|
color-mix(in srgb, var(--color-teal) 8%, transparent)
|
||||||
|
);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-ai-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
margin-bottom: var(--spacing-2);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-ai-header i {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-ai-text {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: var(--line-height-relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
QUICK ACTIONS
|
||||||
|
=========================================== */
|
||||||
|
swp-quick-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-quick-action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
padding: var(--spacing-3);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
font-family: var(--font-family);
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-background);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-quick-action-btn:hover {
|
||||||
|
background: var(--color-background-hover);
|
||||||
|
border-color: var(--color-teal);
|
||||||
|
color: var(--color-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-quick-action-btn i {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
RESPONSIVE
|
||||||
|
=========================================== */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
swp-dashboard-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-side-column {
|
||||||
|
order: -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
swp-page-container {
|
||||||
|
padding: var(--spacing-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-page-header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-page-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
246
PlanTempus.Application/wwwroot/css/sidebar.css
Normal file
246
PlanTempus.Application/wwwroot/css/sidebar.css
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
/**
|
||||||
|
* Sidebar - Side Menu Component
|
||||||
|
*
|
||||||
|
* Navigation sidebar med collapse-funktionalitet
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
SIDE MENU CONTAINER
|
||||||
|
=========================================== */
|
||||||
|
swp-side-menu {
|
||||||
|
grid-row: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
HEADER
|
||||||
|
=========================================== */
|
||||||
|
swp-side-menu-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
height: var(--topbar-height);
|
||||||
|
padding: 0 var(--spacing-4);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-side-menu-header > i {
|
||||||
|
font-size: 26px;
|
||||||
|
color: var(--color-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-side-menu-logo {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle Button */
|
||||||
|
swp-menu-toggle {
|
||||||
|
margin-left: auto;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-menu-toggle:hover {
|
||||||
|
background: var(--color-background-hover);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-menu-toggle i {
|
||||||
|
font-size: 18px;
|
||||||
|
color: inherit;
|
||||||
|
transition: transform var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
NAVIGATION
|
||||||
|
=========================================== */
|
||||||
|
swp-side-menu-nav {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--spacing-3) 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-side-menu-group {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--spacing-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-side-menu-label {
|
||||||
|
display: block;
|
||||||
|
padding: var(--spacing-2) var(--spacing-4) 6px;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
MENU ITEMS
|
||||||
|
=========================================== */
|
||||||
|
swp-side-menu-item,
|
||||||
|
a[is="swp-side-menu-item"] {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-3);
|
||||||
|
padding: 10px var(--spacing-4);
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-side-menu-item:hover,
|
||||||
|
a[is="swp-side-menu-item"]:hover {
|
||||||
|
background: var(--color-background-hover);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-side-menu-item[data-active="true"],
|
||||||
|
a[is="swp-side-menu-item"][data-active="true"] {
|
||||||
|
background: var(--color-teal-light);
|
||||||
|
border-left-color: var(--color-teal);
|
||||||
|
color: var(--color-teal);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-side-menu-item i,
|
||||||
|
a[is="swp-side-menu-item"] i {
|
||||||
|
font-size: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
FOOTER
|
||||||
|
=========================================== */
|
||||||
|
swp-side-menu-footer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
padding: var(--spacing-3) var(--spacing-4);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
margin-top: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-side-menu-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
padding: 10px;
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
font-family: var(--font-family);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-side-menu-action:hover {
|
||||||
|
background: var(--color-background-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-side-menu-action.lock:hover {
|
||||||
|
color: var(--color-amber);
|
||||||
|
border-color: var(--color-amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-side-menu-action.logout:hover {
|
||||||
|
color: var(--color-red);
|
||||||
|
border-color: var(--color-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-side-menu-action i {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
COLLAPSED STATE
|
||||||
|
=========================================== */
|
||||||
|
swp-app-layout.menu-collapsed swp-side-menu {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-app-layout.menu-collapsed swp-side-menu-logo,
|
||||||
|
swp-app-layout.menu-collapsed swp-side-menu-label,
|
||||||
|
swp-app-layout.menu-collapsed swp-side-menu-item span,
|
||||||
|
swp-app-layout.menu-collapsed swp-side-menu-action span,
|
||||||
|
swp-app-layout.menu-collapsed a[is="swp-side-menu-item"] span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-app-layout.menu-collapsed swp-side-menu-header {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-app-layout.menu-collapsed swp-menu-toggle {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-app-layout.menu-collapsed swp-menu-toggle i {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-app-layout.menu-collapsed swp-side-menu-item,
|
||||||
|
swp-app-layout.menu-collapsed a[is="swp-side-menu-item"] {
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--spacing-3);
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-app-layout.menu-collapsed swp-side-menu-item[data-active="true"],
|
||||||
|
swp-app-layout.menu-collapsed a[is="swp-side-menu-item"][data-active="true"] {
|
||||||
|
border-left: none;
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
margin: 0 var(--spacing-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-app-layout.menu-collapsed swp-side-menu-action {
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--spacing-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-app-layout.menu-collapsed swp-side-menu-footer {
|
||||||
|
padding: var(--spacing-3) var(--spacing-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
TOOLTIP (Collapsed State)
|
||||||
|
=========================================== */
|
||||||
|
.swp-menu-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px var(--spacing-3);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
font-family: var(--font-family);
|
||||||
|
color: var(--color-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: var(--z-tooltip);
|
||||||
|
}
|
||||||
258
PlanTempus.Application/wwwroot/css/stats.css
Normal file
258
PlanTempus.Application/wwwroot/css/stats.css
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
/**
|
||||||
|
* Stats - Statistics Components
|
||||||
|
*
|
||||||
|
* Stat bars, cards, values og trends
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
STATS CONTAINER (Grid/Bar/Row)
|
||||||
|
=========================================== */
|
||||||
|
swp-stats-bar,
|
||||||
|
swp-stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: var(--spacing-4);
|
||||||
|
margin-bottom: var(--spacing-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-stats-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--spacing-4);
|
||||||
|
margin-bottom: var(--spacing-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
STAT CARD
|
||||||
|
=========================================== */
|
||||||
|
swp-stat-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
padding: var(--spacing-4) var(--spacing-5);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-stat-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-1);
|
||||||
|
padding: var(--spacing-4);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
STAT VALUE
|
||||||
|
=========================================== */
|
||||||
|
swp-stat-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: var(--line-height-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Larger variant for emphasis */
|
||||||
|
swp-stat-card swp-stat-value,
|
||||||
|
swp-stat-box swp-stat-value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
STAT LABEL
|
||||||
|
=========================================== */
|
||||||
|
swp-stat-label {
|
||||||
|
display: block;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-top: var(--spacing-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-stat-box swp-stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
STAT SUBTITLE
|
||||||
|
=========================================== */
|
||||||
|
swp-stat-subtitle {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-top: var(--spacing-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
STAT TREND / CHANGE
|
||||||
|
=========================================== */
|
||||||
|
swp-stat-trend,
|
||||||
|
swp-stat-change {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-1);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
margin-top: var(--spacing-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-stat-trend i,
|
||||||
|
swp-stat-change i {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Trend Up (positive) */
|
||||||
|
swp-stat-trend.up,
|
||||||
|
swp-stat-change.positive {
|
||||||
|
color: var(--color-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Trend Down (negative) */
|
||||||
|
swp-stat-trend.down,
|
||||||
|
swp-stat-change.negative {
|
||||||
|
color: var(--color-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Neutral trend */
|
||||||
|
swp-stat-trend.neutral {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
COLOR MODIFIERS
|
||||||
|
=========================================== */
|
||||||
|
|
||||||
|
/* Highlight (Primary/Teal) */
|
||||||
|
swp-stat-card.highlight swp-stat-value,
|
||||||
|
swp-stat-box.highlight swp-stat-value,
|
||||||
|
swp-stat-card.teal swp-stat-value {
|
||||||
|
color: var(--color-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success (Green) */
|
||||||
|
swp-stat-card.success swp-stat-value {
|
||||||
|
color: var(--color-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Warning (Amber) */
|
||||||
|
swp-stat-card.warning swp-stat-value,
|
||||||
|
swp-stat-card.amber swp-stat-value {
|
||||||
|
color: var(--color-amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Danger (Red) */
|
||||||
|
swp-stat-card.danger swp-stat-value,
|
||||||
|
swp-stat-card.negative swp-stat-value,
|
||||||
|
swp-stat-card.red swp-stat-value {
|
||||||
|
color: var(--color-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Purple */
|
||||||
|
swp-stat-card.purple swp-stat-value {
|
||||||
|
color: var(--color-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
HIGHLIGHT CARD (Filled Background)
|
||||||
|
=========================================== */
|
||||||
|
swp-stat-card.highlight.filled {
|
||||||
|
background: linear-gradient(135deg, var(--color-teal) 0%, #00695c 100%);
|
||||||
|
color: white;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-stat-card.highlight.filled swp-stat-value {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-stat-card.highlight.filled swp-stat-label {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-stat-card.highlight.filled swp-stat-change {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
QUICK STATS (Compact Variant)
|
||||||
|
=========================================== */
|
||||||
|
swp-quick-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: var(--spacing-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-quick-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--spacing-3);
|
||||||
|
background: var(--color-background);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-quick-stat swp-stat-value {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-quick-stat swp-stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: var(--spacing-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
STAT ITEM (Inline Variant)
|
||||||
|
=========================================== */
|
||||||
|
swp-stat-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: var(--spacing-2) var(--spacing-3);
|
||||||
|
background: var(--color-background);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-stat-item swp-stat-value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-stat-item swp-stat-value.mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-stat-item swp-stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
RESPONSIVE
|
||||||
|
=========================================== */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
swp-stats-bar,
|
||||||
|
swp-stats-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
swp-stats-bar,
|
||||||
|
swp-stats-grid,
|
||||||
|
swp-stats-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-quick-stats {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
180
PlanTempus.Application/wwwroot/css/topbar.css
Normal file
180
PlanTempus.Application/wwwroot/css/topbar.css
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
/**
|
||||||
|
* Topbar - App Header Bar
|
||||||
|
*
|
||||||
|
* Search, notifications og profil-menu
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
TOPBAR CONTAINER
|
||||||
|
=========================================== */
|
||||||
|
swp-app-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 var(--spacing-5);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: var(--z-sticky);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
SEARCH
|
||||||
|
=========================================== */
|
||||||
|
swp-topbar-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: var(--spacing-2) var(--spacing-3);
|
||||||
|
background: var(--color-background);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
width: 320px;
|
||||||
|
transition: border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-topbar-search:focus-within {
|
||||||
|
border-color: var(--color-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-topbar-search i {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-topbar-search input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
font-family: var(--font-family);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-topbar-search input::placeholder {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-topbar-search kbd {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
ACTIONS
|
||||||
|
=========================================== */
|
||||||
|
swp-topbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action Buttons */
|
||||||
|
swp-topbar-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
position: relative;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-topbar-btn:hover {
|
||||||
|
background: var(--color-background-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-topbar-btn i {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notification Badge */
|
||||||
|
swp-notification-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--color-red);
|
||||||
|
color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Divider */
|
||||||
|
swp-topbar-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--color-border);
|
||||||
|
margin: 0 var(--spacing-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
PROFILE TRIGGER
|
||||||
|
=========================================== */
|
||||||
|
swp-topbar-profile {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px var(--spacing-3) 6px 6px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-topbar-profile:hover {
|
||||||
|
background: var(--color-background-hover);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-profile-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-teal);
|
||||||
|
color: white;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-profile-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-profile-name {
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: var(--line-height-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-profile-role {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: var(--line-height-tight);
|
||||||
|
}
|
||||||
308
PlanTempus.Application/wwwroot/email-templates/verify-email.html
Normal file
308
PlanTempus.Application/wwwroot/email-templates/verify-email.html
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="da">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>Bekræft din email - Plan Tempus</title>
|
||||||
|
<!--[if mso]>
|
||||||
|
<noscript>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
</noscript>
|
||||||
|
<![endif]-->
|
||||||
|
<style>
|
||||||
|
/* Reset & Base */
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wrapper */
|
||||||
|
.email-wrapper {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-wrapper-cell {
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container */
|
||||||
|
.email-container {
|
||||||
|
max-width: 600px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logo */
|
||||||
|
.logo-cell {
|
||||||
|
padding: 40px 40px 30px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: block;
|
||||||
|
max-width: 180px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header-cell {
|
||||||
|
padding: 0 40px 20px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e2a4a;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Divider */
|
||||||
|
.divider-cell {
|
||||||
|
padding: 0 40px 30px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider-accent {
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, #e8734a 0%, #f5a882 100%);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider-line {
|
||||||
|
height: 1px;
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider-line-cell {
|
||||||
|
padding: 0 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.content-cell {
|
||||||
|
padding: 0 40px 30px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-body {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-body:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-strong {
|
||||||
|
color: #1e2a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CTA Button */
|
||||||
|
.cta-cell {
|
||||||
|
padding: 0 40px 30px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-button-cell {
|
||||||
|
background-color: #e8734a;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-button {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 16px 40px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fallback */
|
||||||
|
.fallback-cell {
|
||||||
|
padding: 0 40px 40px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-small {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #888888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-small:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-accent {
|
||||||
|
color: #e8734a;
|
||||||
|
text-decoration: underline;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer-cell {
|
||||||
|
padding: 30px 40px 40px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-footer {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #888888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-footer:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-dark {
|
||||||
|
color: #1e2a4a;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Outside footer */
|
||||||
|
.footer-outside {
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-outside-cell {
|
||||||
|
padding: 30px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-copyright {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-copyright:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Wrapper table -->
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" class="email-wrapper">
|
||||||
|
<tr>
|
||||||
|
<td align="center" class="email-wrapper-cell">
|
||||||
|
<!-- Email container -->
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" class="email-container">
|
||||||
|
|
||||||
|
<!-- Logo section -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" class="logo-cell">
|
||||||
|
<img src="/images/poclogo.png" alt="Plan Tempus" width="180" class="logo">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" class="header-cell">
|
||||||
|
<h1 class="header-title">Bekræft din email</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" class="divider-cell">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="80">
|
||||||
|
<tr>
|
||||||
|
<td class="divider-accent"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Welcome text -->
|
||||||
|
<tr>
|
||||||
|
<td class="content-cell">
|
||||||
|
<p class="text-body">
|
||||||
|
Hej <strong class="text-strong">{{USER_NAME}}</strong>,
|
||||||
|
</p>
|
||||||
|
<p class="text-body">
|
||||||
|
Tak fordi du har oprettet en konto hos Plan Tempus. For at komme i gang skal du bekræfte din email-adresse ved at klikke på knappen nedenfor.
|
||||||
|
</p>
|
||||||
|
<p class="text-body">
|
||||||
|
Linket er gyldigt i 24 timer.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" class="cta-cell">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td align="center" class="cta-button-cell">
|
||||||
|
<a href="{{VERIFY_URL}}" target="_blank" class="cta-button">
|
||||||
|
Bekræft email
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Fallback link -->
|
||||||
|
<tr>
|
||||||
|
<td class="fallback-cell">
|
||||||
|
<p class="text-small">
|
||||||
|
Hvis knappen ikke virker, kan du kopiere og indsætte dette link i din browser:
|
||||||
|
</p>
|
||||||
|
<p class="text-small">
|
||||||
|
<a href="{{VERIFY_URL}}" class="link-accent">{{VERIFY_URL}}</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Divider line -->
|
||||||
|
<tr>
|
||||||
|
<td class="divider-line-cell">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="divider-line"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td class="footer-cell">
|
||||||
|
<p class="text-footer">
|
||||||
|
Hvis du ikke har oprettet en konto hos Plan Tempus, kan du ignorere denne email.
|
||||||
|
</p>
|
||||||
|
<p class="text-footer">
|
||||||
|
Har du spørgsmål? Kontakt os på <a href="mailto:support@plantempus.dk" class="link-dark">support@plantempus.dk</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Footer outside card -->
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" class="footer-outside">
|
||||||
|
<tr>
|
||||||
|
<td align="center" class="footer-outside-cell">
|
||||||
|
<p class="text-copyright">
|
||||||
|
© 2026 Plan Tempus. Alle rettigheder forbeholdes.
|
||||||
|
</p>
|
||||||
|
<p class="text-copyright">
|
||||||
|
Plan Tempus ApS • CVR: 12345678 • Danmark
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-Black.woff
Normal file
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-Black.woff
Normal file
Binary file not shown.
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-BlackItalic.woff
Normal file
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-BlackItalic.woff
Normal file
Binary file not shown.
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-Bold.woff
Normal file
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-Bold.woff
Normal file
Binary file not shown.
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-BoldItalic.woff
Normal file
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-BoldItalic.woff
Normal file
Binary file not shown.
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-ExtraBold.woff
Normal file
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-ExtraBold.woff
Normal file
Binary file not shown.
Binary file not shown.
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-ExtraLight.woff
Normal file
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-ExtraLight.woff
Normal file
Binary file not shown.
Binary file not shown.
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-Italic.woff
Normal file
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-Italic.woff
Normal file
Binary file not shown.
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-Light.woff
Normal file
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-Light.woff
Normal file
Binary file not shown.
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-LightItalic.woff
Normal file
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-LightItalic.woff
Normal file
Binary file not shown.
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-Medium.woff
Normal file
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-Medium.woff
Normal file
Binary file not shown.
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-MediumItalic.woff
Normal file
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-MediumItalic.woff
Normal file
Binary file not shown.
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-Regular.woff
Normal file
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-Regular.woff
Normal file
Binary file not shown.
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-SemiBold.woff
Normal file
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-SemiBold.woff
Normal file
Binary file not shown.
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-SemiBoldItalic.woff
Normal file
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-SemiBoldItalic.woff
Normal file
Binary file not shown.
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-Thin.woff
Normal file
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-Thin.woff
Normal file
Binary file not shown.
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-ThinItalic.woff
Normal file
BIN
PlanTempus.Application/wwwroot/fonts/Poppins-ThinItalic.woff
Normal file
Binary file not shown.
BIN
PlanTempus.Application/wwwroot/images/poclogo.png
Normal file
BIN
PlanTempus.Application/wwwroot/images/poclogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2 MiB |
604
PlanTempus.Application/wwwroot/js/app.js
Normal file
604
PlanTempus.Application/wwwroot/js/app.js
Normal file
File diff suppressed because one or more lines are too long
58
PlanTempus.Application/wwwroot/ts/app.ts
Normal file
58
PlanTempus.Application/wwwroot/ts/app.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
/**
|
||||||
|
* Salon OS App
|
||||||
|
*
|
||||||
|
* Main application class that orchestrates all UI controllers
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SidebarController } from './modules/sidebar';
|
||||||
|
import { DrawerController } from './modules/drawers';
|
||||||
|
import { ThemeController } from './modules/theme';
|
||||||
|
import { SearchController } from './modules/search';
|
||||||
|
import { LockScreenController } from './modules/lockscreen';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main application class
|
||||||
|
*/
|
||||||
|
export class App {
|
||||||
|
readonly sidebar: SidebarController;
|
||||||
|
readonly drawers: DrawerController;
|
||||||
|
readonly theme: ThemeController;
|
||||||
|
readonly search: SearchController;
|
||||||
|
readonly lockScreen: LockScreenController;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Initialize controllers
|
||||||
|
this.sidebar = new SidebarController();
|
||||||
|
this.drawers = new DrawerController();
|
||||||
|
this.theme = new ThemeController();
|
||||||
|
this.search = new SearchController();
|
||||||
|
this.lockScreen = new LockScreenController(this.drawers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global app instance
|
||||||
|
*/
|
||||||
|
let app: App;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the application
|
||||||
|
*/
|
||||||
|
function init(): void {
|
||||||
|
app = new App();
|
||||||
|
|
||||||
|
// Expose to window for debugging
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
(window as unknown as { app: App }).app = app;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for DOM ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
export { app };
|
||||||
|
export default App;
|
||||||
226
PlanTempus.Application/wwwroot/ts/modules/drawers.ts
Normal file
226
PlanTempus.Application/wwwroot/ts/modules/drawers.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
/**
|
||||||
|
* Drawer Controller
|
||||||
|
*
|
||||||
|
* Handles all drawer functionality including profile, notifications, and todo drawers
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type DrawerName = 'profile' | 'notification' | 'todo' | 'newTodo';
|
||||||
|
|
||||||
|
export class DrawerController {
|
||||||
|
private profileDrawer: HTMLElement | null = null;
|
||||||
|
private notificationDrawer: HTMLElement | null = null;
|
||||||
|
private todoDrawer: HTMLElement | null = null;
|
||||||
|
private newTodoDrawer: HTMLElement | null = null;
|
||||||
|
private overlay: HTMLElement | null = null;
|
||||||
|
private activeDrawer: DrawerName | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.profileDrawer = document.getElementById('profileDrawer');
|
||||||
|
this.notificationDrawer = document.getElementById('notificationDrawer');
|
||||||
|
this.todoDrawer = document.getElementById('todoDrawer');
|
||||||
|
this.newTodoDrawer = document.getElementById('newTodoDrawer');
|
||||||
|
this.overlay = document.getElementById('drawerOverlay');
|
||||||
|
|
||||||
|
this.setupListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get currently active drawer name
|
||||||
|
*/
|
||||||
|
get active(): DrawerName | null {
|
||||||
|
return this.activeDrawer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a drawer by name
|
||||||
|
*/
|
||||||
|
open(name: DrawerName): void {
|
||||||
|
this.closeAll();
|
||||||
|
|
||||||
|
const drawer = this.getDrawer(name);
|
||||||
|
if (drawer && this.overlay) {
|
||||||
|
drawer.classList.add('active');
|
||||||
|
this.overlay.classList.add('active');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
this.activeDrawer = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close a specific drawer
|
||||||
|
*/
|
||||||
|
close(name: DrawerName): void {
|
||||||
|
const drawer = this.getDrawer(name);
|
||||||
|
drawer?.classList.remove('active');
|
||||||
|
|
||||||
|
// Only hide overlay if no drawers are active
|
||||||
|
if (this.overlay && !document.querySelector('.active[class*="drawer"]')) {
|
||||||
|
this.overlay.classList.remove('active');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.activeDrawer === name) {
|
||||||
|
this.activeDrawer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close all drawers
|
||||||
|
*/
|
||||||
|
closeAll(): void {
|
||||||
|
[this.profileDrawer, this.notificationDrawer, this.todoDrawer, this.newTodoDrawer]
|
||||||
|
.forEach(drawer => drawer?.classList.remove('active'));
|
||||||
|
|
||||||
|
this.overlay?.classList.remove('active');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
this.activeDrawer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open profile drawer
|
||||||
|
*/
|
||||||
|
openProfile(): void {
|
||||||
|
this.open('profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open notification drawer
|
||||||
|
*/
|
||||||
|
openNotification(): void {
|
||||||
|
this.open('notification');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open todo drawer (slides on top of profile)
|
||||||
|
*/
|
||||||
|
openTodo(): void {
|
||||||
|
this.todoDrawer?.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close todo drawer
|
||||||
|
*/
|
||||||
|
closeTodo(): void {
|
||||||
|
this.todoDrawer?.classList.remove('active');
|
||||||
|
this.closeNewTodo();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open new todo drawer
|
||||||
|
*/
|
||||||
|
openNewTodo(): void {
|
||||||
|
this.newTodoDrawer?.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close new todo drawer
|
||||||
|
*/
|
||||||
|
closeNewTodo(): void {
|
||||||
|
this.newTodoDrawer?.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark all notifications as read
|
||||||
|
*/
|
||||||
|
markAllNotificationsRead(): void {
|
||||||
|
if (!this.notificationDrawer) return;
|
||||||
|
|
||||||
|
const unreadItems = this.notificationDrawer.querySelectorAll<HTMLElement>(
|
||||||
|
'swp-notification-item[data-unread="true"]'
|
||||||
|
);
|
||||||
|
unreadItems.forEach(item => item.removeAttribute('data-unread'));
|
||||||
|
|
||||||
|
const badge = document.querySelector<HTMLElement>('swp-notification-badge');
|
||||||
|
if (badge) {
|
||||||
|
badge.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDrawer(name: DrawerName): HTMLElement | null {
|
||||||
|
switch (name) {
|
||||||
|
case 'profile': return this.profileDrawer;
|
||||||
|
case 'notification': return this.notificationDrawer;
|
||||||
|
case 'todo': return this.todoDrawer;
|
||||||
|
case 'newTodo': return this.newTodoDrawer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupListeners(): void {
|
||||||
|
// Profile drawer triggers
|
||||||
|
document.getElementById('profileTrigger')
|
||||||
|
?.addEventListener('click', () => this.openProfile());
|
||||||
|
document.getElementById('drawerClose')
|
||||||
|
?.addEventListener('click', () => this.close('profile'));
|
||||||
|
|
||||||
|
// Notification drawer triggers
|
||||||
|
document.getElementById('notificationsBtn')
|
||||||
|
?.addEventListener('click', () => this.openNotification());
|
||||||
|
document.getElementById('notificationDrawerClose')
|
||||||
|
?.addEventListener('click', () => this.close('notification'));
|
||||||
|
document.getElementById('markAllRead')
|
||||||
|
?.addEventListener('click', () => this.markAllNotificationsRead());
|
||||||
|
|
||||||
|
// Todo drawer triggers
|
||||||
|
document.getElementById('openTodoDrawer')
|
||||||
|
?.addEventListener('click', () => this.openTodo());
|
||||||
|
document.getElementById('todoDrawerBack')
|
||||||
|
?.addEventListener('click', () => this.closeTodo());
|
||||||
|
|
||||||
|
// New todo drawer triggers
|
||||||
|
document.getElementById('addTodoBtn')
|
||||||
|
?.addEventListener('click', () => this.openNewTodo());
|
||||||
|
document.getElementById('newTodoDrawerBack')
|
||||||
|
?.addEventListener('click', () => this.closeNewTodo());
|
||||||
|
document.getElementById('cancelNewTodo')
|
||||||
|
?.addEventListener('click', () => this.closeNewTodo());
|
||||||
|
document.getElementById('saveNewTodo')
|
||||||
|
?.addEventListener('click', () => this.closeNewTodo());
|
||||||
|
|
||||||
|
// Overlay click closes all
|
||||||
|
this.overlay?.addEventListener('click', () => this.closeAll());
|
||||||
|
|
||||||
|
// Escape key closes all
|
||||||
|
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') this.closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Todo interactions
|
||||||
|
this.todoDrawer?.addEventListener('click', (e) => this.handleTodoClick(e));
|
||||||
|
|
||||||
|
// Visibility options
|
||||||
|
document.addEventListener('click', (e) => this.handleVisibilityClick(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleTodoClick(e: Event): void {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const todoItem = target.closest<HTMLElement>('swp-todo-item');
|
||||||
|
const checkbox = target.closest<HTMLElement>('swp-todo-checkbox');
|
||||||
|
|
||||||
|
if (checkbox && todoItem) {
|
||||||
|
const isCompleted = todoItem.dataset.completed === 'true';
|
||||||
|
if (isCompleted) {
|
||||||
|
todoItem.removeAttribute('data-completed');
|
||||||
|
} else {
|
||||||
|
todoItem.dataset.completed = 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle section collapse
|
||||||
|
const sectionHeader = target.closest<HTMLElement>('swp-todo-section-header');
|
||||||
|
if (sectionHeader) {
|
||||||
|
const section = sectionHeader.closest<HTMLElement>('swp-todo-section');
|
||||||
|
section?.classList.toggle('collapsed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleVisibilityClick(e: Event): void {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const option = target.closest<HTMLElement>('swp-visibility-option');
|
||||||
|
|
||||||
|
if (option) {
|
||||||
|
document.querySelectorAll<HTMLElement>('swp-visibility-option')
|
||||||
|
.forEach(o => o.classList.remove('active'));
|
||||||
|
option.classList.add('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
182
PlanTempus.Application/wwwroot/ts/modules/lockscreen.ts
Normal file
182
PlanTempus.Application/wwwroot/ts/modules/lockscreen.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
/**
|
||||||
|
* Lock Screen Controller
|
||||||
|
*
|
||||||
|
* Handles PIN-based lock screen functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DrawerController } from './drawers';
|
||||||
|
|
||||||
|
export class LockScreenController {
|
||||||
|
private static readonly CORRECT_PIN = '1234'; // Demo PIN
|
||||||
|
|
||||||
|
private lockScreen: HTMLElement | null = null;
|
||||||
|
private pinInput: HTMLElement | null = null;
|
||||||
|
private pinKeypad: HTMLElement | null = null;
|
||||||
|
private lockTimeEl: HTMLElement | null = null;
|
||||||
|
private pinDigits: NodeListOf<HTMLElement> | null = null;
|
||||||
|
private currentPin = '';
|
||||||
|
private drawers: DrawerController | null = null;
|
||||||
|
|
||||||
|
constructor(drawers?: DrawerController) {
|
||||||
|
this.drawers = drawers ?? null;
|
||||||
|
this.lockScreen = document.getElementById('lockScreen');
|
||||||
|
this.pinInput = document.getElementById('pinInput');
|
||||||
|
this.pinKeypad = document.getElementById('pinKeypad');
|
||||||
|
this.lockTimeEl = document.getElementById('lockTime');
|
||||||
|
this.pinDigits = this.pinInput?.querySelectorAll<HTMLElement>('swp-pin-digit') ?? null;
|
||||||
|
|
||||||
|
this.setupListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if lock screen is active
|
||||||
|
*/
|
||||||
|
get isActive(): boolean {
|
||||||
|
return this.lockScreen?.classList.contains('active') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the lock screen
|
||||||
|
*/
|
||||||
|
show(): void {
|
||||||
|
this.drawers?.closeAll();
|
||||||
|
|
||||||
|
if (this.lockScreen) {
|
||||||
|
this.lockScreen.classList.add('active');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentPin = '';
|
||||||
|
this.updateDisplay();
|
||||||
|
|
||||||
|
// Update lock time
|
||||||
|
if (this.lockTimeEl) {
|
||||||
|
this.lockTimeEl.textContent = `Låst kl. ${this.formatTime()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the lock screen
|
||||||
|
*/
|
||||||
|
hide(): void {
|
||||||
|
if (this.lockScreen) {
|
||||||
|
this.lockScreen.classList.remove('active');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentPin = '';
|
||||||
|
this.updateDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatTime(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const hours = now.getHours().toString().padStart(2, '0');
|
||||||
|
const minutes = now.getMinutes().toString().padStart(2, '0');
|
||||||
|
return `${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateDisplay(): void {
|
||||||
|
if (!this.pinDigits) return;
|
||||||
|
|
||||||
|
this.pinDigits.forEach((digit, index) => {
|
||||||
|
digit.classList.remove('filled', 'error');
|
||||||
|
if (index < this.currentPin.length) {
|
||||||
|
digit.textContent = '•';
|
||||||
|
digit.classList.add('filled');
|
||||||
|
} else {
|
||||||
|
digit.textContent = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private showError(): void {
|
||||||
|
if (!this.pinDigits) return;
|
||||||
|
|
||||||
|
this.pinDigits.forEach(digit => digit.classList.add('error'));
|
||||||
|
|
||||||
|
// Shake animation
|
||||||
|
this.pinInput?.classList.add('shake');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.currentPin = '';
|
||||||
|
this.updateDisplay();
|
||||||
|
this.pinInput?.classList.remove('shake');
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
private verify(): void {
|
||||||
|
if (this.currentPin === LockScreenController.CORRECT_PIN) {
|
||||||
|
this.hide();
|
||||||
|
} else {
|
||||||
|
this.showError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private addDigit(digit: string): void {
|
||||||
|
if (this.currentPin.length >= 4) return;
|
||||||
|
|
||||||
|
this.currentPin += digit;
|
||||||
|
this.updateDisplay();
|
||||||
|
|
||||||
|
// Auto-verify when 4 digits entered
|
||||||
|
if (this.currentPin.length === 4) {
|
||||||
|
setTimeout(() => this.verify(), 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeDigit(): void {
|
||||||
|
if (this.currentPin.length === 0) return;
|
||||||
|
this.currentPin = this.currentPin.slice(0, -1);
|
||||||
|
this.updateDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearPin(): void {
|
||||||
|
this.currentPin = '';
|
||||||
|
this.updateDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupListeners(): void {
|
||||||
|
// Keypad click handler
|
||||||
|
this.pinKeypad?.addEventListener('click', (e) => this.handleKeypadClick(e));
|
||||||
|
|
||||||
|
// Keyboard input
|
||||||
|
document.addEventListener('keydown', (e) => this.handleKeyboard(e));
|
||||||
|
|
||||||
|
// Lock button in sidebar
|
||||||
|
document.querySelector<HTMLElement>('swp-side-menu-action.lock')
|
||||||
|
?.addEventListener('click', () => this.show());
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleKeypadClick(e: Event): void {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const key = target.closest<HTMLElement>('swp-pin-key');
|
||||||
|
|
||||||
|
if (!key) return;
|
||||||
|
|
||||||
|
const digit = key.dataset.digit;
|
||||||
|
const action = key.dataset.action;
|
||||||
|
|
||||||
|
if (digit) {
|
||||||
|
this.addDigit(digit);
|
||||||
|
} else if (action === 'backspace') {
|
||||||
|
this.removeDigit();
|
||||||
|
} else if (action === 'clear') {
|
||||||
|
this.clearPin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleKeyboard(e: KeyboardEvent): void {
|
||||||
|
if (!this.isActive) return;
|
||||||
|
|
||||||
|
// Prevent default to avoid other interactions
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (e.key >= '0' && e.key <= '9') {
|
||||||
|
this.addDigit(e.key);
|
||||||
|
} else if (e.key === 'Backspace') {
|
||||||
|
this.removeDigit();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
this.clearPin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
106
PlanTempus.Application/wwwroot/ts/modules/search.ts
Normal file
106
PlanTempus.Application/wwwroot/ts/modules/search.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
/**
|
||||||
|
* Search Controller
|
||||||
|
*
|
||||||
|
* Handles global search functionality and keyboard shortcuts
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class SearchController {
|
||||||
|
private input: HTMLInputElement | null = null;
|
||||||
|
private container: HTMLElement | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.input = document.getElementById('globalSearch') as HTMLInputElement | null;
|
||||||
|
this.container = document.querySelector<HTMLElement>('swp-topbar-search');
|
||||||
|
|
||||||
|
this.setupListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current search value
|
||||||
|
*/
|
||||||
|
get value(): string {
|
||||||
|
return this.input?.value ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set search value
|
||||||
|
*/
|
||||||
|
set value(val: string) {
|
||||||
|
if (this.input) {
|
||||||
|
this.input.value = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focus the search input
|
||||||
|
*/
|
||||||
|
focus(): void {
|
||||||
|
this.input?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blur the search input
|
||||||
|
*/
|
||||||
|
blur(): void {
|
||||||
|
this.input?.blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the search input
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupListeners(): void {
|
||||||
|
// Keyboard shortcuts
|
||||||
|
document.addEventListener('keydown', (e) => this.handleKeyboard(e));
|
||||||
|
|
||||||
|
// Input handlers
|
||||||
|
if (this.input) {
|
||||||
|
this.input.addEventListener('input', (e) => this.handleInput(e));
|
||||||
|
|
||||||
|
// Prevent form submission if wrapped in form
|
||||||
|
const form = this.input.closest('form');
|
||||||
|
form?.addEventListener('submit', (e) => this.handleSubmit(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleKeyboard(e: KeyboardEvent): void {
|
||||||
|
// Cmd/Ctrl + K to focus search
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape to blur search when focused
|
||||||
|
if (e.key === 'Escape' && document.activeElement === this.input) {
|
||||||
|
this.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleInput(e: Event): void {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
const query = target.value.trim();
|
||||||
|
|
||||||
|
// Emit custom event for search
|
||||||
|
document.dispatchEvent(new CustomEvent('app:search', {
|
||||||
|
detail: { query },
|
||||||
|
bubbles: true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSubmit(e: Event): void {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const query = this.value.trim();
|
||||||
|
if (!query) return;
|
||||||
|
|
||||||
|
// Emit custom event for search submit
|
||||||
|
document.dispatchEvent(new CustomEvent('app:search-submit', {
|
||||||
|
detail: { query },
|
||||||
|
bubbles: true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
96
PlanTempus.Application/wwwroot/ts/modules/sidebar.ts
Normal file
96
PlanTempus.Application/wwwroot/ts/modules/sidebar.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
/**
|
||||||
|
* Sidebar Controller
|
||||||
|
*
|
||||||
|
* Handles sidebar collapse/expand and tooltip functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class SidebarController {
|
||||||
|
private menuToggle: HTMLElement | null = null;
|
||||||
|
private appLayout: HTMLElement | null = null;
|
||||||
|
private menuTooltip: HTMLElement | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.menuToggle = document.getElementById('menuToggle');
|
||||||
|
this.appLayout = document.querySelector('swp-app-layout');
|
||||||
|
this.menuTooltip = document.getElementById('menuTooltip');
|
||||||
|
|
||||||
|
this.setupListeners();
|
||||||
|
this.setupTooltips();
|
||||||
|
this.restoreState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if sidebar is collapsed
|
||||||
|
*/
|
||||||
|
get isCollapsed(): boolean {
|
||||||
|
return this.appLayout?.classList.contains('menu-collapsed') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle sidebar collapsed state
|
||||||
|
*/
|
||||||
|
toggle(): void {
|
||||||
|
if (!this.appLayout) return;
|
||||||
|
|
||||||
|
this.appLayout.classList.toggle('menu-collapsed');
|
||||||
|
localStorage.setItem('sidebar-collapsed', String(this.isCollapsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse the sidebar
|
||||||
|
*/
|
||||||
|
collapse(): void {
|
||||||
|
this.appLayout?.classList.add('menu-collapsed');
|
||||||
|
localStorage.setItem('sidebar-collapsed', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand the sidebar
|
||||||
|
*/
|
||||||
|
expand(): void {
|
||||||
|
this.appLayout?.classList.remove('menu-collapsed');
|
||||||
|
localStorage.setItem('sidebar-collapsed', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupListeners(): void {
|
||||||
|
this.menuToggle?.addEventListener('click', () => this.toggle());
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupTooltips(): void {
|
||||||
|
if (!this.menuTooltip) return;
|
||||||
|
|
||||||
|
const menuItems = document.querySelectorAll<HTMLElement>('swp-side-menu-item[data-tooltip]');
|
||||||
|
|
||||||
|
menuItems.forEach(item => {
|
||||||
|
item.addEventListener('mouseenter', () => this.showTooltip(item));
|
||||||
|
item.addEventListener('mouseleave', () => this.hideTooltip());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private showTooltip(item: HTMLElement): void {
|
||||||
|
if (!this.isCollapsed || !this.menuTooltip) return;
|
||||||
|
|
||||||
|
const rect = item.getBoundingClientRect();
|
||||||
|
const tooltipText = item.dataset.tooltip;
|
||||||
|
|
||||||
|
if (!tooltipText) return;
|
||||||
|
|
||||||
|
this.menuTooltip.textContent = tooltipText;
|
||||||
|
this.menuTooltip.style.left = `${rect.right + 8}px`;
|
||||||
|
this.menuTooltip.style.top = `${rect.top + rect.height / 2}px`;
|
||||||
|
this.menuTooltip.style.transform = 'translateY(-50%)';
|
||||||
|
this.menuTooltip.showPopover();
|
||||||
|
}
|
||||||
|
|
||||||
|
private hideTooltip(): void {
|
||||||
|
this.menuTooltip?.hidePopover();
|
||||||
|
}
|
||||||
|
|
||||||
|
private restoreState(): void {
|
||||||
|
if (!this.appLayout) return;
|
||||||
|
|
||||||
|
if (localStorage.getItem('sidebar-collapsed') === 'true') {
|
||||||
|
this.appLayout.classList.add('menu-collapsed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
120
PlanTempus.Application/wwwroot/ts/modules/theme.ts
Normal file
120
PlanTempus.Application/wwwroot/ts/modules/theme.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
/**
|
||||||
|
* Theme Controller
|
||||||
|
*
|
||||||
|
* Handles dark/light mode switching and system preference detection
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Theme = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
export class ThemeController {
|
||||||
|
private static readonly STORAGE_KEY = 'theme-preference';
|
||||||
|
private static readonly DARK_CLASS = 'dark-mode';
|
||||||
|
private static readonly LIGHT_CLASS = 'light-mode';
|
||||||
|
|
||||||
|
private root: HTMLElement;
|
||||||
|
private themeOptions: NodeListOf<HTMLElement>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.root = document.documentElement;
|
||||||
|
this.themeOptions = document.querySelectorAll<HTMLElement>('swp-theme-option');
|
||||||
|
|
||||||
|
this.applyTheme(this.current);
|
||||||
|
this.updateUI();
|
||||||
|
this.setupListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current theme setting
|
||||||
|
*/
|
||||||
|
get current(): Theme {
|
||||||
|
const stored = localStorage.getItem(ThemeController.STORAGE_KEY) as Theme | null;
|
||||||
|
if (stored === 'dark' || stored === 'light' || stored === 'system') {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
return 'system';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if dark mode is currently active
|
||||||
|
*/
|
||||||
|
get isDark(): boolean {
|
||||||
|
return this.root.classList.contains(ThemeController.DARK_CLASS) ||
|
||||||
|
(this.systemPrefersDark && !this.root.classList.contains(ThemeController.LIGHT_CLASS));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if system prefers dark mode
|
||||||
|
*/
|
||||||
|
get systemPrefersDark(): boolean {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set theme and persist preference
|
||||||
|
*/
|
||||||
|
set(theme: Theme): void {
|
||||||
|
localStorage.setItem(ThemeController.STORAGE_KEY, theme);
|
||||||
|
this.applyTheme(theme);
|
||||||
|
this.updateUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle between light and dark themes
|
||||||
|
*/
|
||||||
|
toggle(): void {
|
||||||
|
this.set(this.isDark ? 'light' : 'dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyTheme(theme: Theme): void {
|
||||||
|
this.root.classList.remove(ThemeController.DARK_CLASS, ThemeController.LIGHT_CLASS);
|
||||||
|
|
||||||
|
if (theme === 'dark') {
|
||||||
|
this.root.classList.add(ThemeController.DARK_CLASS);
|
||||||
|
} else if (theme === 'light') {
|
||||||
|
this.root.classList.add(ThemeController.LIGHT_CLASS);
|
||||||
|
}
|
||||||
|
// 'system' leaves both classes off, letting CSS media query handle it
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateUI(): void {
|
||||||
|
if (!this.themeOptions) return;
|
||||||
|
|
||||||
|
const darkActive = this.isDark;
|
||||||
|
|
||||||
|
this.themeOptions.forEach(option => {
|
||||||
|
const theme = option.dataset.theme as Theme;
|
||||||
|
const isActive = (theme === 'dark' && darkActive) || (theme === 'light' && !darkActive);
|
||||||
|
option.classList.toggle('active', isActive);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupListeners(): void {
|
||||||
|
// Theme option clicks
|
||||||
|
this.themeOptions.forEach(option => {
|
||||||
|
option.addEventListener('click', (e) => this.handleOptionClick(e));
|
||||||
|
});
|
||||||
|
|
||||||
|
// System theme changes
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
.addEventListener('change', () => this.handleSystemChange());
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleOptionClick(e: Event): void {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const option = target.closest<HTMLElement>('swp-theme-option');
|
||||||
|
|
||||||
|
if (option) {
|
||||||
|
const theme = option.dataset.theme as Theme;
|
||||||
|
if (theme) {
|
||||||
|
this.set(theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSystemChange(): void {
|
||||||
|
// Only react to system changes if we're using system preference
|
||||||
|
if (this.current === 'system') {
|
||||||
|
this.updateUI();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
PlanTempus.Application/wwwroot/ts/tsconfig.json
Normal file
22
PlanTempus.Application/wwwroot/ts/tsconfig.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": false,
|
||||||
|
"outDir": "../js/app",
|
||||||
|
"rootDir": ".",
|
||||||
|
"sourceMap": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"./**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ using Microsoft.ApplicationInsights;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using PlanTempus.Core.Database.ConnectionFactory;
|
using PlanTempus.Core.Database.ConnectionFactory;
|
||||||
|
using PlanTempus.Core.Telemetry;
|
||||||
|
|
||||||
namespace PlanTempus.Components.Outbox;
|
namespace PlanTempus.Components.Outbox;
|
||||||
|
|
||||||
|
|
@ -10,19 +11,23 @@ public class OutboxListener : BackgroundService
|
||||||
private readonly IDbConnectionFactory _connectionFactory;
|
private readonly IDbConnectionFactory _connectionFactory;
|
||||||
private readonly ICommandHandler _commandHandler;
|
private readonly ICommandHandler _commandHandler;
|
||||||
private readonly TelemetryClient _telemetryClient;
|
private readonly TelemetryClient _telemetryClient;
|
||||||
|
private readonly IMessageChannel<string> _notificationChannel;
|
||||||
|
|
||||||
public OutboxListener(
|
public OutboxListener(
|
||||||
IDbConnectionFactory connectionFactory,
|
IDbConnectionFactory connectionFactory,
|
||||||
ICommandHandler commandHandler,
|
ICommandHandler commandHandler,
|
||||||
TelemetryClient telemetryClient)
|
TelemetryClient telemetryClient,
|
||||||
|
IMessageChannel<string> notificationChannel)
|
||||||
{
|
{
|
||||||
_connectionFactory = connectionFactory;
|
_connectionFactory = connectionFactory;
|
||||||
_commandHandler = commandHandler;
|
_commandHandler = commandHandler;
|
||||||
_telemetryClient = telemetryClient;
|
_telemetryClient = telemetryClient;
|
||||||
|
_notificationChannel = notificationChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
|
Console.WriteLine("OutboxListener starting - listening for outbox_messages");
|
||||||
_telemetryClient.TrackTrace("OutboxListener starting - listening for outbox_messages");
|
_telemetryClient.TrackTrace("OutboxListener starting - listening for outbox_messages");
|
||||||
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
|
@ -33,6 +38,7 @@ public class OutboxListener : BackgroundService
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
{
|
{
|
||||||
|
Console.WriteLine($"OutboxListener error: {ex.Message}");
|
||||||
_telemetryClient.TrackException(ex);
|
_telemetryClient.TrackException(ex);
|
||||||
await Task.Delay(5000, stoppingToken);
|
await Task.Delay(5000, stoppingToken);
|
||||||
}
|
}
|
||||||
|
|
@ -44,18 +50,10 @@ public class OutboxListener : BackgroundService
|
||||||
await using var conn = (NpgsqlConnection)_connectionFactory.Create();
|
await using var conn = (NpgsqlConnection)_connectionFactory.Create();
|
||||||
await conn.OpenAsync(stoppingToken);
|
await conn.OpenAsync(stoppingToken);
|
||||||
|
|
||||||
conn.Notification += async (_, e) =>
|
conn.Notification += (_, e) =>
|
||||||
{
|
{
|
||||||
_telemetryClient.TrackTrace($"Outbox notification received: {e.Payload}");
|
Console.WriteLine($"Notification event received: {e.Payload}");
|
||||||
|
_notificationChannel.Writer.TryWrite(e.Payload);
|
||||||
try
|
|
||||||
{
|
|
||||||
await _commandHandler.Handle(new ProcessOutboxCommand());
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_telemetryClient.TrackException(ex);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await using (var cmd = new NpgsqlCommand("LISTEN outbox_messages;", conn))
|
await using (var cmd = new NpgsqlCommand("LISTEN outbox_messages;", conn))
|
||||||
|
|
@ -63,14 +61,32 @@ public class OutboxListener : BackgroundService
|
||||||
await cmd.ExecuteNonQueryAsync(stoppingToken);
|
await cmd.ExecuteNonQueryAsync(stoppingToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Console.WriteLine("OutboxListener now listening on outbox_messages channel");
|
||||||
_telemetryClient.TrackTrace("OutboxListener now listening on outbox_messages channel");
|
_telemetryClient.TrackTrace("OutboxListener now listening on outbox_messages channel");
|
||||||
|
|
||||||
// Process any pending messages on startup
|
// Process any pending messages on startup
|
||||||
|
Console.WriteLine("Processing pending messages on startup...");
|
||||||
await _commandHandler.Handle(new ProcessOutboxCommand());
|
await _commandHandler.Handle(new ProcessOutboxCommand());
|
||||||
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
await conn.WaitAsync(stoppingToken);
|
await conn.WaitAsync(stoppingToken);
|
||||||
|
|
||||||
|
while (_notificationChannel.Reader.TryRead(out var payload))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Outbox notification received from channel: {payload}");
|
||||||
|
_telemetryClient.TrackTrace($"Outbox notification received: {payload}");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _commandHandler.Handle(new ProcessOutboxCommand());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Error processing outbox: {ex.Message}");
|
||||||
|
_telemetryClient.TrackException(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,23 +13,28 @@ public class ProcessOutboxHandler(
|
||||||
{
|
{
|
||||||
public async Task<CommandResponse> Handle(ProcessOutboxCommand command)
|
public async Task<CommandResponse> Handle(ProcessOutboxCommand command)
|
||||||
{
|
{
|
||||||
|
Console.WriteLine($"ProcessOutboxHandler started");
|
||||||
telemetryClient.TrackTrace($"ProcessOutboxHandler started");
|
telemetryClient.TrackTrace($"ProcessOutboxHandler started");
|
||||||
|
|
||||||
var messages = await outboxService.GetPendingAsync(command.BatchSize);
|
var messages = await outboxService.GetPendingAsync(command.BatchSize);
|
||||||
|
|
||||||
|
Console.WriteLine($"ProcessOutboxHandler found {messages.Count} pending messages");
|
||||||
telemetryClient.TrackTrace($"ProcessOutboxHandler found {messages.Count} pending messages");
|
telemetryClient.TrackTrace($"ProcessOutboxHandler found {messages.Count} pending messages");
|
||||||
|
|
||||||
foreach (var message in messages)
|
foreach (var message in messages)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
Console.WriteLine($"Processing message {message.Id} of type {message.Type}");
|
||||||
telemetryClient.TrackTrace($"Processing message {message.Id} of type {message.Type}");
|
telemetryClient.TrackTrace($"Processing message {message.Id} of type {message.Type}");
|
||||||
await ProcessMessageAsync(message);
|
await ProcessMessageAsync(message);
|
||||||
await outboxService.MarkAsSentAsync(message.Id);
|
await outboxService.MarkAsSentAsync(message.Id);
|
||||||
|
Console.WriteLine($"Message {message.Id} marked as sent");
|
||||||
telemetryClient.TrackTrace($"Message {message.Id} marked as sent");
|
telemetryClient.TrackTrace($"Message {message.Id} marked as sent");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
Console.WriteLine($"Message {message.Id} failed: {ex.Message}");
|
||||||
telemetryClient.TrackTrace($"Message {message.Id} failed: {ex.Message}");
|
telemetryClient.TrackTrace($"Message {message.Id} failed: {ex.Message}");
|
||||||
await outboxService.MarkAsFailedAsync(message.Id, ex.Message);
|
await outboxService.MarkAsFailedAsync(message.Id, ex.Message);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,6 @@
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\Core\PlanTempus.Core.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="Users\Delete\" />
|
<Folder Include="Users\Delete\" />
|
||||||
<Folder Include="Users\Update\" />
|
<Folder Include="Users\Update\" />
|
||||||
|
|
@ -21,4 +17,8 @@
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.3.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.3.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\PlanTempus.Core\PlanTempus.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue