From a1059adf0678f2e811d0efb05eedcdb280a353d8 Mon Sep 17 00:00:00 2001 From: "Janus C. H. Knudsen" Date: Fri, 23 Jan 2026 20:03:24 +0100 Subject: [PATCH] Adds salary specifications with detailed accordion view Introduces new salary specification feature with interactive accordion component Implements detailed salary breakdown including: - Salary specification JSON data model - Salary specification page with printable view - Accordion component for expanding/collapsing salary details - Localization support for new salary labels Enhances employee salary transparency and detail presentation --- CLAUDE.md | 2 +- .../EmployeeDetailSalary/Default.cshtml | 161 ++++-- .../EmployeeDetailSalaryViewComponent.cs | 118 ++++- .../Data/salarySpecificationMock.json | 330 +++++++++++++ .../Pages/SalarySpecification.cshtml | 461 ++++++++++++++++++ .../Pages/SalarySpecification.cshtml.cs | 46 ++ .../Localization/Translations/da.json | 14 +- .../Localization/Translations/en.json | 14 +- .../Features/_Shared/Pages/_Layout.cshtml | 1 + .../wwwroot/css/COMPONENT-CATALOG.md | 47 +- .../wwwroot/css/accordion.css | 203 ++++++++ .../wwwroot/css/employees.css | 56 +++ .../wwwroot/ts/modules/accordion.ts | 190 ++++++++ .../wwwroot/ts/modules/employees.ts | 16 + 14 files changed, 1613 insertions(+), 46 deletions(-) create mode 100644 PlanTempus.Application/Features/Employees/Data/salarySpecificationMock.json create mode 100644 PlanTempus.Application/Features/Employees/Pages/SalarySpecification.cshtml create mode 100644 PlanTempus.Application/Features/Employees/Pages/SalarySpecification.cshtml.cs create mode 100644 PlanTempus.Application/wwwroot/css/accordion.css create mode 100644 PlanTempus.Application/wwwroot/ts/modules/accordion.ts diff --git a/CLAUDE.md b/CLAUDE.md index b61619d..0b8bff7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ PlanTempus is a .NET 9 web application built with ASP.NET Core Razor Pages. It u ## 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. +- **calpoc** = Calendar POC projekt located at `../Calendar/wwwroot` (TypeScript calendar component with offline-first architecture, drag-and-drop, NovaDI, EventBus). When user mentions "calpoc", refer to this folder. ## Build and Development Commands diff --git a/PlanTempus.Application/Features/Employees/Components/EmployeeDetailSalary/Default.cshtml b/PlanTempus.Application/Features/Employees/Components/EmployeeDetailSalary/Default.cshtml index 3d1053b..ff5dbc8 100644 --- a/PlanTempus.Application/Features/Employees/Components/EmployeeDetailSalary/Default.cshtml +++ b/PlanTempus.Application/Features/Employees/Components/EmployeeDetailSalary/Default.cshtml @@ -52,7 +52,24 @@ - + + + + @Model.LabelCommission + + + + @Model.LabelProductCommission + + + + @Model.LabelServiceCommission + + + + + + @Model.LabelSupplements @@ -72,47 +89,111 @@ - - - - - @Model.LabelCommission - - - - @Model.LabelProductCommission - - - - @Model.LabelServiceCommission - - - - - - - - @Model.LabelSalaryHistory - - - - @Model.LabelPeriod - @Model.LabelGrossSalary - - - @foreach (var item in Model.SalaryHistory) - { - - @item.Period - @item.GrossSalary - - - } - - + + + + @Model.LabelSpecifications + + + @foreach (var spec in Model.Specifications) + { + + + + @spec.Period + @spec.Weeks.Sum(w => w.NormalHours + w.OvertimeHours)t + + + + @spec.GrossSalaryFormatted + @Model.LabelTotal + + + + + + + + + + + @Model.LabelNormalRate: + @spec.Config.HourlyRateFormatted + + + @Model.LabelWeeklyNorm: + @spec.Config.WeeklyHoursFormatted + + + @Model.LabelOvertimeMultiplier: + @spec.Config.OvertimeFormatted + + + @Model.LabelMinimum: + @spec.Config.MinimumFormatted + + + @Model.LabelProvision: + @spec.Config.CommissionFormatted + + + + + + + + @Model.LabelWeek + Timer + Overtid + Ferie + Services + Produkter + Minimum + Provision + I alt + + @foreach (var week in spec.Weeks) + { + + Uge @week.WeekNumber + @week.NormalHoursFormatted + @week.OvertimeHoursFormatted + @week.VacationDaysFormatted + @week.ServiceRevenueFormatted + @week.ProductRevenueFormatted + @week.MinimumThresholdFormatted + @week.CommissionFormatted + @week.TotalPayFormatted + + } + + TOTAL + @(spec.Weeks.Sum(w => w.NormalHours))t + @(spec.Weeks.Sum(w => w.OvertimeHours))t + @(spec.Weeks.Sum(w => w.VacationDays)) dg + @(spec.Weeks.Sum(w => w.ServiceRevenue).ToString("N0", System.Globalization.CultureInfo.GetCultureInfo("da-DK"))) kr + @(spec.Weeks.Sum(w => w.ProductRevenue).ToString("N0", System.Globalization.CultureInfo.GetCultureInfo("da-DK"))) kr + - + @(spec.Weeks.Sum(w => w.Commission).ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("da-DK"))) kr + @(spec.Weeks.Sum(w => w.TotalPay).ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("da-DK"))) kr + + + + + + + Se lønberegning + + + + + } + + +
@@ -177,7 +258,7 @@ - + @Model.LabelSupplements diff --git a/PlanTempus.Application/Features/Employees/Components/EmployeeDetailSalary/EmployeeDetailSalaryViewComponent.cs b/PlanTempus.Application/Features/Employees/Components/EmployeeDetailSalary/EmployeeDetailSalaryViewComponent.cs index e9a5b52..8d1a5c8 100644 --- a/PlanTempus.Application/Features/Employees/Components/EmployeeDetailSalary/EmployeeDetailSalaryViewComponent.cs +++ b/PlanTempus.Application/Features/Employees/Components/EmployeeDetailSalary/EmployeeDetailSalaryViewComponent.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Microsoft.AspNetCore.Mvc; using PlanTempus.Application.Features.Localization.Services; @@ -6,18 +7,23 @@ namespace PlanTempus.Application.Features.Employees.Components; public class EmployeeDetailSalaryViewComponent : ViewComponent { private readonly ILocalizationService _localization; + private readonly IWebHostEnvironment _environment; - public EmployeeDetailSalaryViewComponent(ILocalizationService localization) + public EmployeeDetailSalaryViewComponent(ILocalizationService localization, IWebHostEnvironment environment) { _localization = localization; + _environment = environment; } public IViewComponentResult Invoke(string key) { var employee = EmployeeDetailCatalog.Get(key); + var salaryData = LoadSalarySpecifications(); var model = new EmployeeDetailSalaryViewModel { + // Salary specifications from JSON + Specifications = salaryData.Specifications, // Data BankAccount = employee.BankAccount, TaxCard = employee.TaxCard, @@ -89,7 +95,21 @@ public class EmployeeDetailSalaryViewComponent : ViewComponent ProductCommissionValue = employee.ProductCommissionValue, ServiceCommissionValue = employee.ServiceCommissionValue, - // Mock salary history + // Labels for specifications + LabelSpecifications = _localization.Get("employees.detail.salary.specifications"), + LabelWeek = _localization.Get("employees.detail.salary.week"), + LabelNormalHours = _localization.Get("employees.detail.salary.normalhours"), + LabelOvertimeHours = _localization.Get("employees.detail.salary.overtimehours"), + LabelVacationDays = _localization.Get("employees.detail.salary.vacationdays"), + LabelServiceRevenue = _localization.Get("employees.detail.salary.servicerevenue"), + LabelProductRevenue = _localization.Get("employees.detail.salary.productrevenue"), + LabelMinimumThreshold = _localization.Get("employees.detail.salary.minimumthreshold"), + LabelTotal = _localization.Get("employees.detail.salary.total"), + LabelWeeklyNorm = _localization.Get("employees.detail.salary.weeklynorm"), + LabelOvertimeMultiplier = _localization.Get("employees.detail.salary.overtimemultiplier"), + LabelMinimum = _localization.Get("employees.detail.salary.minimum"), + + // Mock salary history (kept for backwards compatibility) SalaryHistory = new List { new() { Period = "Januar 2026", GrossSalary = "34.063,50 kr" }, @@ -102,10 +122,23 @@ public class EmployeeDetailSalaryViewComponent : ViewComponent return View(model); } + + private SalarySpecificationRoot LoadSalarySpecifications() + { + var jsonPath = Path.Combine(_environment.ContentRootPath, "Features", "Employees", "Data", "salarySpecificationMock.json"); + var json = System.IO.File.ReadAllText(jsonPath); + return JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + })!; + } } public class EmployeeDetailSalaryViewModel { + // Salary Specifications (from JSON) + public List Specifications { get; init; } = new(); + // Data public required string BankAccount { get; init; } public required string TaxCard { get; init; } @@ -160,6 +193,20 @@ public class EmployeeDetailSalaryViewModel public required string LabelProductCommissionFull { get; init; } public required string LabelServiceCommissionFull { get; init; } + // Labels for specifications accordion + public required string LabelSpecifications { get; init; } + public required string LabelWeek { get; init; } + public required string LabelNormalHours { get; init; } + public required string LabelOvertimeHours { get; init; } + public required string LabelVacationDays { get; init; } + public required string LabelServiceRevenue { get; init; } + public required string LabelProductRevenue { get; init; } + public required string LabelMinimumThreshold { get; init; } + public required string LabelTotal { get; init; } + public required string LabelWeeklyNorm { get; init; } + public required string LabelOvertimeMultiplier { get; init; } + public required string LabelMinimum { get; init; } + // Rate values (for drawer inputs) public required string NormalRateValue { get; init; } public required string OvertimeRateValue { get; init; } @@ -186,3 +233,70 @@ public class SalaryHistoryItem public required string Period { get; init; } public required string GrossSalary { get; init; } } + +// DTOs for salary specification JSON +public class SalarySpecificationRoot +{ + public List Specifications { get; init; } = new(); +} + +public class SalarySpecificationDto +{ + public required string Period { get; init; } + public required string PeriodKey { get; init; } + public decimal GrossSalary { get; init; } + public decimal BasePay { get; init; } + public decimal OvertimePay { get; init; } + public decimal ServiceCommission { get; init; } + public decimal ProductCommission { get; init; } + public SalaryConfigDto Config { get; init; } = new(); + public List Weeks { get; init; } = new(); + + // Formatted values for display + public string GrossSalaryFormatted => GrossSalary.ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("da-DK")) + " kr"; + public string BasePayFormatted => BasePay.ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("da-DK")) + " kr"; + public string OvertimePayFormatted => OvertimePay.ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("da-DK")) + " kr"; + public string TotalCommission => (ServiceCommission + ProductCommission).ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("da-DK")) + " kr"; +} + +public class SalaryConfigDto +{ + public decimal HourlyRate { get; init; } + public int WeeklyHours { get; init; } + public decimal OvertimeMultiplier { get; init; } + public decimal MinimumPerHour { get; init; } + public int ServiceCommissionPct { get; init; } + public int ProductCommissionPct { get; init; } + + // Formatted values + public string HourlyRateFormatted => HourlyRate.ToString("N0", System.Globalization.CultureInfo.GetCultureInfo("da-DK")) + " kr"; + public string WeeklyHoursFormatted => WeeklyHours + "t/uge"; + public string OvertimeFormatted => "+" + Math.Round((OvertimeMultiplier - 1) * 100) + "%"; + public string MinimumFormatted => MinimumPerHour.ToString("N0", System.Globalization.CultureInfo.GetCultureInfo("da-DK")) + " kr/time"; + public string CommissionFormatted => ServiceCommissionPct + "% services · " + ProductCommissionPct + "% produkter"; +} + +public class SalaryWeekDto +{ + public int WeekNumber { get; init; } + public int NormalHours { get; init; } + public int OvertimeHours { get; init; } + public int VacationDays { get; init; } + public decimal ServiceRevenue { get; init; } + public decimal ProductRevenue { get; init; } + public decimal MinimumThreshold { get; init; } + public decimal Commission { get; init; } + public decimal TotalPay { get; init; } + + // Formatted values + public string NormalHoursFormatted => NormalHours + "t"; + public string OvertimeHoursFormatted => OvertimeHours + "t"; + public string VacationDaysFormatted => VacationDays + " dg"; + public string ServiceRevenueFormatted => ServiceRevenue.ToString("N0", System.Globalization.CultureInfo.GetCultureInfo("da-DK")) + " kr"; + public string ProductRevenueFormatted => ProductRevenue.ToString("N0", System.Globalization.CultureInfo.GetCultureInfo("da-DK")) + " kr"; + public string MinimumThresholdFormatted => MinimumThreshold.ToString("N0", System.Globalization.CultureInfo.GetCultureInfo("da-DK")) + " kr"; + public string CommissionFormatted => Commission.ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("da-DK")) + " kr"; + public string TotalPayFormatted => TotalPay.ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("da-DK")) + " kr"; + public bool HasOvertime => OvertimeHours > 0; + public bool HasCommission => Commission > 0; +} diff --git a/PlanTempus.Application/Features/Employees/Data/salarySpecificationMock.json b/PlanTempus.Application/Features/Employees/Data/salarySpecificationMock.json new file mode 100644 index 0000000..578d88e --- /dev/null +++ b/PlanTempus.Application/Features/Employees/Data/salarySpecificationMock.json @@ -0,0 +1,330 @@ +{ + "specifications": [ + { + "period": "Januar 2026", + "periodKey": "2026-01", + "grossSalary": 34063.50, + "basePay": 28800.00, + "overtimePay": 1800.00, + "serviceCommission": 2463.50, + "productCommission": 1000.00, + "config": { + "hourlyRate": 185, + "weeklyHours": 37, + "overtimeMultiplier": 1.5, + "minimumPerHour": 220, + "serviceCommissionPct": 15, + "productCommissionPct": 15 + }, + "weeks": [ + { + "weekNumber": 1, + "normalHours": 37, + "overtimeHours": 2, + "vacationDays": 0, + "serviceRevenue": 12500, + "productRevenue": 3200, + "minimumThreshold": 8580, + "commission": 1068.00, + "totalPay": 8643.00 + }, + { + "weekNumber": 2, + "normalHours": 37, + "overtimeHours": 0, + "vacationDays": 0, + "serviceRevenue": 10800, + "productRevenue": 2100, + "minimumThreshold": 8140, + "commission": 712.50, + "totalPay": 7558.00 + }, + { + "weekNumber": 3, + "normalHours": 37, + "overtimeHours": 3, + "vacationDays": 0, + "serviceRevenue": 14200, + "productRevenue": 4500, + "minimumThreshold": 8800, + "commission": 1485.00, + "totalPay": 9318.00 + }, + { + "weekNumber": 4, + "normalHours": 32, + "overtimeHours": 0, + "vacationDays": 1, + "serviceRevenue": 9200, + "productRevenue": 1800, + "minimumThreshold": 7040, + "commission": 594.00, + "totalPay": 6544.50 + } + ] + }, + { + "period": "December 2025", + "periodKey": "2025-12", + "grossSalary": 31845.00, + "basePay": 27380.00, + "overtimePay": 555.00, + "serviceCommission": 2910.00, + "productCommission": 1000.00, + "config": { + "hourlyRate": 185, + "weeklyHours": 37, + "overtimeMultiplier": 1.5, + "minimumPerHour": 220, + "serviceCommissionPct": 15, + "productCommissionPct": 15 + }, + "weeks": [ + { + "weekNumber": 49, + "normalHours": 37, + "overtimeHours": 0, + "vacationDays": 0, + "serviceRevenue": 11200, + "productRevenue": 2800, + "minimumThreshold": 8140, + "commission": 879.00, + "totalPay": 7724.00 + }, + { + "weekNumber": 50, + "normalHours": 37, + "overtimeHours": 2, + "vacationDays": 0, + "serviceRevenue": 13500, + "productRevenue": 3600, + "minimumThreshold": 8580, + "commission": 1278.00, + "totalPay": 8833.00 + }, + { + "weekNumber": 51, + "normalHours": 37, + "overtimeHours": 0, + "vacationDays": 0, + "serviceRevenue": 12800, + "productRevenue": 2400, + "minimumThreshold": 8140, + "commission": 1059.00, + "totalPay": 7904.00 + }, + { + "weekNumber": 52, + "normalHours": 30, + "overtimeHours": 0, + "vacationDays": 2, + "serviceRevenue": 8500, + "productRevenue": 1500, + "minimumThreshold": 6600, + "commission": 510.00, + "totalPay": 6060.00 + } + ] + }, + { + "period": "November 2025", + "periodKey": "2025-11", + "grossSalary": 33290.25, + "basePay": 27380.00, + "overtimePay": 1110.00, + "serviceCommission": 3550.25, + "productCommission": 1250.00, + "config": { + "hourlyRate": 185, + "weeklyHours": 37, + "overtimeMultiplier": 1.5, + "minimumPerHour": 220, + "serviceCommissionPct": 15, + "productCommissionPct": 15 + }, + "weeks": [ + { + "weekNumber": 45, + "normalHours": 37, + "overtimeHours": 0, + "vacationDays": 0, + "serviceRevenue": 11800, + "productRevenue": 3100, + "minimumThreshold": 8140, + "commission": 1014.00, + "totalPay": 7859.00 + }, + { + "weekNumber": 46, + "normalHours": 37, + "overtimeHours": 4, + "vacationDays": 0, + "serviceRevenue": 15200, + "productRevenue": 4200, + "minimumThreshold": 9020, + "commission": 1557.00, + "totalPay": 9667.00 + }, + { + "weekNumber": 47, + "normalHours": 37, + "overtimeHours": 0, + "vacationDays": 0, + "serviceRevenue": 10500, + "productRevenue": 2600, + "minimumThreshold": 8140, + "commission": 744.00, + "totalPay": 7589.00 + }, + { + "weekNumber": 48, + "normalHours": 37, + "overtimeHours": 0, + "vacationDays": 0, + "serviceRevenue": 12300, + "productRevenue": 2900, + "minimumThreshold": 8140, + "commission": 1059.00, + "totalPay": 7904.00 + } + ] + }, + { + "period": "Oktober 2025", + "periodKey": "2025-10", + "grossSalary": 32156.75, + "basePay": 27380.00, + "overtimePay": 832.50, + "serviceCommission": 2944.25, + "productCommission": 1000.00, + "config": { + "hourlyRate": 185, + "weeklyHours": 37, + "overtimeMultiplier": 1.5, + "minimumPerHour": 220, + "serviceCommissionPct": 15, + "productCommissionPct": 15 + }, + "weeks": [ + { + "weekNumber": 40, + "normalHours": 37, + "overtimeHours": 0, + "vacationDays": 0, + "serviceRevenue": 10200, + "productRevenue": 2400, + "minimumThreshold": 8140, + "commission": 669.00, + "totalPay": 7514.00 + }, + { + "weekNumber": 41, + "normalHours": 37, + "overtimeHours": 3, + "vacationDays": 0, + "serviceRevenue": 13800, + "productRevenue": 3200, + "minimumThreshold": 8800, + "commission": 1230.00, + "totalPay": 9063.00 + }, + { + "weekNumber": 42, + "normalHours": 0, + "overtimeHours": 0, + "vacationDays": 5, + "serviceRevenue": 0, + "productRevenue": 0, + "minimumThreshold": 0, + "commission": 0, + "totalPay": 0 + }, + { + "weekNumber": 43, + "normalHours": 37, + "overtimeHours": 0, + "vacationDays": 0, + "serviceRevenue": 11500, + "productRevenue": 2700, + "minimumThreshold": 8140, + "commission": 909.00, + "totalPay": 7754.00 + }, + { + "weekNumber": 44, + "normalHours": 37, + "overtimeHours": 0, + "vacationDays": 0, + "serviceRevenue": 10900, + "productRevenue": 2500, + "minimumThreshold": 8140, + "commission": 789.00, + "totalPay": 7634.00 + } + ] + }, + { + "period": "September 2025", + "periodKey": "2025-09", + "grossSalary": 34520.00, + "basePay": 27380.00, + "overtimePay": 1387.50, + "serviceCommission": 4252.50, + "productCommission": 1500.00, + "config": { + "hourlyRate": 185, + "weeklyHours": 37, + "overtimeMultiplier": 1.5, + "minimumPerHour": 220, + "serviceCommissionPct": 15, + "productCommissionPct": 15 + }, + "weeks": [ + { + "weekNumber": 36, + "normalHours": 37, + "overtimeHours": 2, + "vacationDays": 0, + "serviceRevenue": 14500, + "productRevenue": 4800, + "minimumThreshold": 8580, + "commission": 1608.00, + "totalPay": 9163.00 + }, + { + "weekNumber": 37, + "normalHours": 37, + "overtimeHours": 3, + "vacationDays": 0, + "serviceRevenue": 16200, + "productRevenue": 5100, + "minimumThreshold": 8800, + "commission": 1875.00, + "totalPay": 9708.00 + }, + { + "weekNumber": 38, + "normalHours": 37, + "overtimeHours": 0, + "vacationDays": 0, + "serviceRevenue": 12100, + "productRevenue": 3200, + "minimumThreshold": 8140, + "commission": 1074.00, + "totalPay": 7919.00 + }, + { + "weekNumber": 39, + "normalHours": 37, + "overtimeHours": 0, + "vacationDays": 0, + "serviceRevenue": 11800, + "productRevenue": 2900, + "minimumThreshold": 8140, + "commission": 984.00, + "totalPay": 7829.00 + } + ] + } + ] +} diff --git a/PlanTempus.Application/Features/Employees/Pages/SalarySpecification.cshtml b/PlanTempus.Application/Features/Employees/Pages/SalarySpecification.cshtml new file mode 100644 index 0000000..b667108 --- /dev/null +++ b/PlanTempus.Application/Features/Employees/Pages/SalarySpecification.cshtml @@ -0,0 +1,461 @@ +@page "/medarbejdere/loenspecifikation/{period}" +@model PlanTempus.Application.Features.Employees.Pages.SalarySpecificationModel +@{ + Layout = null; + var spec = Model.Specification!; + var culture = System.Globalization.CultureInfo.GetCultureInfo("da-DK"); +} + + + + + + Lønspecifikation – @spec.Period + + + + + + + +
+ +
+
+

Lønspecifikation

+

Periode: @spec.Period

+
+
+
Medarbejdernr.: @Model.EmployeeNumber
+
+ Medarbejder:@Model.EmployeeName + Afdeling:@Model.Department + Ansættelse:@Model.EmploymentType +
+
+
+ + +
+
+
Bruttoløn (@spec.Period)
+

@spec.GrossSalary.ToString("N2", culture) kr

+
+
+ + +
+
+
+

Samlet lønopgørelse

+
+
+ + + + + + + + + + + + + + + + + + + + + +
LøndelBeløb
Grundløn inkl. overarbejde@((spec.BasePay + spec.OvertimePay).ToString("N2", culture)) kr
Provision i alt@((spec.ServiceCommission + spec.ProductCommission).ToString("N2", culture)) kr
Bruttoløn@spec.GrossSalary.ToString("N2", culture) kr
+
+
+ +
+
+

Satser

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
TypeVærdi
Timeløn@spec.Config.HourlyRate.ToString("N0", culture) kr
Normtid@spec.Config.WeeklyHours t/uge
Overtidstillæg@spec.Config.OvertimeFormatted
Minimum pr. time@spec.Config.MinimumPerHour.ToString("N0", culture) kr
+
+
+
+ + +
+
+

Hurtigt resumé

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
NøglepunktVærdi
Normaltimer@spec.Weeks.Sum(w => w.NormalHours) t
Overarbejde@spec.Weeks.Sum(w => w.OvertimeHours) t
Provision (services + produkter)@((spec.ServiceCommission + spec.ProductCommission).ToString("N2", culture)) kr
Feriedage@spec.Weeks.Sum(w => w.VacationDays) dage
+
+
+ +
+
Overblik
+
Lønspecifikation · @spec.Period
+
+ +
+ + +
+
+

Arbejdstid pr. uge

+
+
+ + + + + + + + + + + + + + @foreach (var week in spec.Weeks) + { + + + + + + + + + + } + + + + + + + + + + +
UgeNormaltimerOvertidServicesProdukterProvisionI alt
Uge @week.WeekNumber@week.NormalHours t@week.OvertimeHours t@week.ServiceRevenue.ToString("N0", culture) kr@week.ProductRevenue.ToString("N0", culture) kr@week.Commission.ToString("N2", culture) kr@week.TotalPay.ToString("N2", culture) kr
I alt@spec.Weeks.Sum(w => w.NormalHours) t@spec.Weeks.Sum(w => w.OvertimeHours) t@spec.Weeks.Sum(w => w.ServiceRevenue).ToString("N0", culture) kr@spec.Weeks.Sum(w => w.ProductRevenue).ToString("N0", culture) kr@spec.Weeks.Sum(w => w.Commission).ToString("N2", culture) kr@spec.Weeks.Sum(w => w.TotalPay).ToString("N2", culture) kr
+

+ Satser: Normal @spec.Config.HourlyRate.ToString("N0", culture) kr/time. + Overtid (@((spec.Config.OvertimeMultiplier - 1) * 100)%) @((spec.Config.HourlyRate * spec.Config.OvertimeMultiplier).ToString("N2", culture)) kr/time. +

+
+
+ +
+
+

Provision

+
+
+

+ Services: @spec.Config.ServiceCommissionPct% af omsætning over minimum (@spec.Config.MinimumPerHour.ToString("N0", culture) kr/time).
+ Produkter: @spec.Config.ProductCommissionPct% af salg. +

+ + + + + + + + + + + + @foreach (var week in spec.Weeks) + { + + + + + + + + } + + + + + + + + +
UgeService oms.Produkt oms.MinimumProvision
Uge @week.WeekNumber@week.ServiceRevenue.ToString("N0", culture) kr@week.ProductRevenue.ToString("N0", culture) kr@week.MinimumThreshold.ToString("N0", culture) kr@week.Commission.ToString("N2", culture) kr
I alt@spec.Weeks.Sum(w => w.ServiceRevenue).ToString("N0", culture) kr@spec.Weeks.Sum(w => w.ProductRevenue).ToString("N0", culture) kr-@spec.Weeks.Sum(w => w.Commission).ToString("N2", culture) kr
+
+
+ +
+
Detaljer
+
Lønspecifikation · @spec.Period
+
+
+ + diff --git a/PlanTempus.Application/Features/Employees/Pages/SalarySpecification.cshtml.cs b/PlanTempus.Application/Features/Employees/Pages/SalarySpecification.cshtml.cs new file mode 100644 index 0000000..a45d787 --- /dev/null +++ b/PlanTempus.Application/Features/Employees/Pages/SalarySpecification.cshtml.cs @@ -0,0 +1,46 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using PlanTempus.Application.Features.Employees.Components; + +namespace PlanTempus.Application.Features.Employees.Pages; + +public class SalarySpecificationModel : PageModel +{ + private readonly IWebHostEnvironment _environment; + + public SalarySpecificationModel(IWebHostEnvironment environment) + { + _environment = environment; + } + + public SalarySpecificationDto? Specification { get; private set; } + public string EmployeeName { get; private set; } = "Emma Larsen"; + public string EmployeeNumber { get; private set; } = "EMP-001"; + public string Department { get; private set; } = "Frisør"; + public string EmploymentType { get; private set; } = "Fuldtid (37 t/uge)"; + + public IActionResult OnGet(string period) + { + var specs = LoadSalarySpecifications(); + Specification = specs.FirstOrDefault(s => s.PeriodKey == period); + + if (Specification == null) + { + return NotFound(); + } + + return Page(); + } + + private List LoadSalarySpecifications() + { + var jsonPath = Path.Combine(_environment.ContentRootPath, "Features", "Employees", "Data", "salarySpecificationMock.json"); + var json = System.IO.File.ReadAllText(jsonPath); + var root = JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + return root?.Specifications ?? new List(); + } +} diff --git a/PlanTempus.Application/Features/Localization/Translations/da.json b/PlanTempus.Application/Features/Localization/Translations/da.json index b310564..6109947 100644 --- a/PlanTempus.Application/Features/Localization/Translations/da.json +++ b/PlanTempus.Application/Features/Localization/Translations/da.json @@ -494,7 +494,19 @@ "saturdaysupplementfull": "8-21 Lørdage (udenfor arbejdstid)", "commission": "Provisionsberegning", "productcommissionfull": "Provision på produktsalg", - "servicecommissionfull": "Provision på servicesalg" + "servicecommissionfull": "Provision på servicesalg", + "specifications": "Lønspecifikationer", + "week": "Uge", + "normalhours": "Normtimer", + "overtimehours": "Overtid", + "vacationdays": "Feriedage", + "servicerevenue": "Services", + "productrevenue": "Produkter", + "minimumthreshold": "Minimum", + "total": "I alt", + "weeklynorm": "Normtid", + "overtimemultiplier": "Overtid", + "minimum": "Minimum" }, "hr": { "contractdocuments": "Kontrakt & Dokumenter", diff --git a/PlanTempus.Application/Features/Localization/Translations/en.json b/PlanTempus.Application/Features/Localization/Translations/en.json index a59a76d..b7adf82 100644 --- a/PlanTempus.Application/Features/Localization/Translations/en.json +++ b/PlanTempus.Application/Features/Localization/Translations/en.json @@ -494,7 +494,19 @@ "saturdaysupplementfull": "8-21 Saturdays (outside working hours)", "commission": "Commission calculation", "productcommissionfull": "Commission on product sales", - "servicecommissionfull": "Commission on service sales" + "servicecommissionfull": "Commission on service sales", + "specifications": "Salary specifications", + "week": "Week", + "normalhours": "Normal hours", + "overtimehours": "Overtime", + "vacationdays": "Vacation days", + "servicerevenue": "Services", + "productrevenue": "Products", + "minimumthreshold": "Minimum", + "total": "Total", + "weeklynorm": "Weekly norm", + "overtimemultiplier": "Overtime", + "minimum": "Minimum" }, "hr": { "contractdocuments": "Contract & Documents", diff --git a/PlanTempus.Application/Features/_Shared/Pages/_Layout.cshtml b/PlanTempus.Application/Features/_Shared/Pages/_Layout.cshtml index e4bd330..9a6e870 100644 --- a/PlanTempus.Application/Features/_Shared/Pages/_Layout.cshtml +++ b/PlanTempus.Application/Features/_Shared/Pages/_Layout.cshtml @@ -28,6 +28,7 @@ + diff --git a/PlanTempus.Application/wwwroot/css/COMPONENT-CATALOG.md b/PlanTempus.Application/wwwroot/css/COMPONENT-CATALOG.md index 47a5727..6e1fd60 100644 --- a/PlanTempus.Application/wwwroot/css/COMPONENT-CATALOG.md +++ b/PlanTempus.Application/wwwroot/css/COMPONENT-CATALOG.md @@ -572,6 +572,50 @@ Dashed border knap til tilføjelse af elementer. --- +## Accordion (accordion.css) + +Genbrugeligt accordion component med expand/collapse animation og single-open behavior. + +```html + + + + + Titel + Subtitle + + + + 1.234 kr + Label + + + + + + + + + + + +``` + +**Features:** +- Single-open behavior (kun en udvidet ad gangen) +- Smooth expand/collapse animation (250ms/200ms) +- Caret icon roterer 180 ved expand +- Config row (`swp-config-row`) til key-value info + +**TypeScript:** +```typescript +import { Accordion } from './modules/accordion'; + +const accordion = new Accordion('#my-accordion', { singleOpen: true }); +``` + +--- + ## Fil Reference | Fil | Indhold | @@ -582,7 +626,8 @@ Dashed border knap til tilføjelse af elementer. | `components.css` | Buttons, badges, cards, section-label, add-button, avatars, icon-btn, **swp-data-table** | | `stats.css` | Stat cards, stat rows | | `tabs.css` | Tab bar, tab content | -| `employees.css` | User info, edit forms, document lists, context styles (.employees-list, .salary-history, .stats-bookings) | +| `accordion.css` | Accordion component (swp-accordion, swp-accordion-item, expand/collapse) | +| `employees.css` | User info, edit forms, document lists, context styles (.employees-list, .salary-history, .salary-specifications, .stats-bookings) | | `account.css` | Account/billing styles, context styles (.invoice-history) | | `bookings.css` | Booking list items | | `notifications.css` | Notification items | diff --git a/PlanTempus.Application/wwwroot/css/accordion.css b/PlanTempus.Application/wwwroot/css/accordion.css new file mode 100644 index 0000000..7391157 --- /dev/null +++ b/PlanTempus.Application/wwwroot/css/accordion.css @@ -0,0 +1,203 @@ +/** + * Accordion Component + * + * Generic reusable accordion with expand/collapse behavior. + * Based on POC employee-card pattern from poc-loen-provision.html. + * + * Usage: + * + * + * + * + * Title + * Subtitle + * + * + * + * Value + * Label + * + * + * + * + * + * + * + * + * + * + * + */ + +/* =========================================== + ACCORDION CONTAINER + =========================================== */ +swp-accordion { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +/* =========================================== + ACCORDION ITEM + =========================================== */ +swp-accordion-item { + display: block; + background: var(--color-surface); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + overflow: hidden; +} + +/* =========================================== + ACCORDION HEADER (clickable) + =========================================== */ +swp-accordion-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-5) var(--spacing-6); + cursor: pointer; + transition: background var(--transition-fast); + user-select: none; +} + +swp-accordion-header:hover { + background: var(--color-background-alt); +} + +/* Info section (left side) */ +swp-accordion-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +swp-accordion-title { + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + color: var(--color-text); +} + +swp-accordion-meta { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + +/* Summary section (right side values) */ +swp-accordion-summary { + display: flex; + align-items: center; + gap: var(--spacing-6); +} + +swp-summary-item { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; +} + +swp-summary-value { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + font-family: var(--font-mono); + color: var(--color-text); +} + +swp-summary-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-text-secondary); +} + +/* Toggle icon */ +swp-accordion-toggle { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-secondary); + transition: transform 200ms ease; + margin-left: var(--spacing-4); + flex-shrink: 0; +} + +swp-accordion-toggle i { + font-size: 20px; +} + +/* Expanded state - rotate toggle */ +swp-accordion-item.expanded swp-accordion-toggle { + transform: rotate(180deg); +} + +/* =========================================== + ACCORDION CONTENT + =========================================== */ +swp-accordion-content { + display: none; + border-top: 1px solid var(--color-border); + overflow: hidden; +} + +/* Expanded state - show content */ +swp-accordion-item.expanded swp-accordion-content { + display: block; +} + +/* =========================================== + CONFIG ROW (inside accordion content) + =========================================== */ +swp-config-row { + display: flex; + gap: var(--spacing-6); + padding: var(--spacing-5) var(--spacing-6); + background: var(--color-background-alt); + border-bottom: 1px solid var(--color-border); + flex-wrap: wrap; +} + +swp-config-item { + display: flex; + align-items: center; + gap: var(--spacing-2); + font-size: var(--font-size-sm); +} + +swp-config-label { + color: var(--color-text-secondary); +} + +swp-config-value { + font-weight: var(--font-weight-medium); + color: var(--color-text); +} + +swp-config-value.mono { + font-family: var(--font-mono); +} + +/* =========================================== + ACCORDION TABLE WRAPPER + =========================================== */ +swp-accordion-table { + display: block; + padding: var(--spacing-6); +} + +/* =========================================== + ACCORDION FOOTER (inside accordion content) + =========================================== */ +swp-accordion-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--spacing-3); + padding: var(--spacing-4) var(--spacing-6); + background: var(--color-background-alt); + border-top: 1px solid var(--color-border); +} diff --git a/PlanTempus.Application/wwwroot/css/employees.css b/PlanTempus.Application/wwwroot/css/employees.css index b05ff19..d13aa53 100644 --- a/PlanTempus.Application/wwwroot/css/employees.css +++ b/PlanTempus.Application/wwwroot/css/employees.css @@ -954,3 +954,59 @@ swp-employee-display { } } } + +/* =========================================== + SALARY SPECIFICATIONS ACCORDION + Reuses: swp-accordion (accordion.css), swp-data-table (components.css) + =========================================== */ +swp-card.salary-specifications { + margin-top: var(--spacing-8); +} + +swp-card.salary-specifications swp-accordion { + padding: 0 var(--spacing-6) var(--spacing-6); +} + +/* Table columns for weeks data (9 columns) */ +swp-card.salary-specifications swp-data-table.specification-weeks { + grid-template-columns: 70px repeat(8, 1fr); +} + +/* Cell styling */ +swp-card.salary-specifications swp-data-table-cell { + padding: var(--spacing-3) var(--spacing-2); + font-size: var(--font-size-sm); +} + +swp-card.salary-specifications swp-data-table-cell.mono { + font-family: var(--font-mono); +} + +swp-card.salary-specifications swp-data-table-cell.warning { + color: var(--color-amber); +} + +swp-card.salary-specifications swp-data-table-cell.highlight { + color: var(--color-teal); + font-weight: var(--font-weight-medium); +} + +/* Footer row styling */ +swp-card.salary-specifications swp-data-table-footer { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + background: var(--color-background-alt); + border-top: 1px solid var(--color-border); +} + +swp-card.salary-specifications swp-data-table-footer swp-data-table-cell { + font-weight: var(--font-weight-semibold); + padding: var(--spacing-4) var(--spacing-2); +} + +swp-card.salary-specifications swp-data-table-footer swp-data-table-cell:first-child { + font-size: var(--font-size-xs); + text-transform: uppercase; + letter-spacing: 0.5px; +} diff --git a/PlanTempus.Application/wwwroot/ts/modules/accordion.ts b/PlanTempus.Application/wwwroot/ts/modules/accordion.ts new file mode 100644 index 0000000..543e495 --- /dev/null +++ b/PlanTempus.Application/wwwroot/ts/modules/accordion.ts @@ -0,0 +1,190 @@ +/** + * Accordion Controller + * + * Generic accordion component with smooth expand/collapse animations. + * Supports single-open behavior (only one item expanded at a time). + */ + +export interface AccordionOptions { + /** Only allow one item to be expanded at a time (default: true) */ + singleOpen?: boolean; + /** Animation duration for expand in ms (default: 250) */ + expandDuration?: number; + /** Animation duration for collapse in ms (default: 200) */ + collapseDuration?: number; +} + +export class Accordion { + private container: HTMLElement; + private singleOpen: boolean; + private expandDuration: number; + private collapseDuration: number; + + constructor(selector: string | HTMLElement, options: AccordionOptions = {}) { + // Get container element + if (typeof selector === 'string') { + const el = document.querySelector(selector); + if (!el) { + console.warn(`Accordion: Element not found for selector "${selector}"`); + return; + } + this.container = el; + } else { + this.container = selector; + } + + // Set options with defaults + this.singleOpen = options.singleOpen ?? true; + this.expandDuration = options.expandDuration ?? 250; + this.collapseDuration = options.collapseDuration ?? 200; + + this.setupEventListeners(); + } + + /** + * Setup click listeners on accordion headers + */ + private setupEventListeners(): void { + const headers = this.container.querySelectorAll('swp-accordion-header'); + + headers.forEach(header => { + header.addEventListener('click', (e) => { + // Don't toggle if clicking on interactive elements + const target = e.target as HTMLElement; + if (target.closest('input, button, a, select')) return; + + const item = header.closest('swp-accordion-item'); + if (item) { + this.toggle(item); + } + }); + }); + } + + /** + * Toggle an accordion item + */ + toggle(item: HTMLElement): void { + const isExpanded = item.classList.contains('expanded'); + + if (isExpanded) { + this.collapse(item); + } else { + // Close other items first if single-open mode + if (this.singleOpen) { + this.container.querySelectorAll('swp-accordion-item.expanded').forEach(otherItem => { + if (otherItem !== item) { + this.collapse(otherItem); + } + }); + } + this.expand(item); + } + } + + /** + * Expand an accordion item with animation + */ + expand(item: HTMLElement): void { + const content = item.querySelector('swp-accordion-content'); + const toggle = item.querySelector('swp-accordion-toggle'); + + if (!content) return; + + // Add expanded class immediately for CSS to show content + item.classList.add('expanded'); + + // Animate toggle icon rotation + toggle?.animate([ + { transform: 'rotate(0deg)' }, + { transform: 'rotate(180deg)' } + ], { + duration: this.expandDuration, + easing: 'ease-out', + fill: 'forwards' + }); + + // Animate content height + const height = content.scrollHeight; + content.animate([ + { height: '0px', opacity: 0 }, + { height: `${height}px`, opacity: 1 } + ], { + duration: this.expandDuration, + easing: 'ease-out', + fill: 'forwards' + }); + } + + /** + * Collapse an accordion item with animation + */ + collapse(item: HTMLElement): void { + const content = item.querySelector('swp-accordion-content'); + const toggle = item.querySelector('swp-accordion-toggle'); + + if (!content) return; + + // Animate toggle icon rotation + toggle?.animate([ + { transform: 'rotate(180deg)' }, + { transform: 'rotate(0deg)' } + ], { + duration: this.collapseDuration, + easing: 'ease-out', + fill: 'forwards' + }); + + // Animate content height + const height = content.scrollHeight; + const animation = content.animate([ + { height: `${height}px`, opacity: 1 }, + { height: '0px', opacity: 0 } + ], { + duration: this.collapseDuration, + easing: 'ease-out', + fill: 'forwards' + }); + + // Remove expanded class after animation completes + animation.onfinish = () => { + item.classList.remove('expanded'); + }; + } + + /** + * Expand all items (only useful when singleOpen is false) + */ + expandAll(): void { + this.container.querySelectorAll('swp-accordion-item:not(.expanded)').forEach(item => { + this.expand(item); + }); + } + + /** + * Collapse all items + */ + collapseAll(): void { + this.container.querySelectorAll('swp-accordion-item.expanded').forEach(item => { + this.collapse(item); + }); + } + + /** + * Get all expanded items + */ + getExpanded(): HTMLElement[] { + return Array.from(this.container.querySelectorAll('swp-accordion-item.expanded')); + } +} + +/** + * Initialize all accordions on the page + */ +export function initAccordions(options: AccordionOptions = {}): Accordion[] { + const accordions: Accordion[] = []; + document.querySelectorAll('swp-accordion').forEach(container => { + accordions.push(new Accordion(container, options)); + }); + return accordions; +} diff --git a/PlanTempus.Application/wwwroot/ts/modules/employees.ts b/PlanTempus.Application/wwwroot/ts/modules/employees.ts index 75d025d..62642bb 100644 --- a/PlanTempus.Application/wwwroot/ts/modules/employees.ts +++ b/PlanTempus.Application/wwwroot/ts/modules/employees.ts @@ -1,4 +1,5 @@ import { createChart } from '@sevenweirdpeople/swp-charting'; +import { Accordion, initAccordions } from './accordion'; /** * Employees Controller @@ -72,6 +73,7 @@ export class EmployeesController { private ratesSync: RatesSyncController | null = null; private scheduleController: ScheduleController | null = null; private statsController: EmployeeStatsController | null = null; + private salaryAccordions: Accordion[] = []; private listView: HTMLElement | null = null; private detailView: HTMLElement | null = null; @@ -91,6 +93,20 @@ export class EmployeesController { this.ratesSync = new RatesSyncController(); this.scheduleController = new ScheduleController(); this.statsController = new EmployeeStatsController(); + this.initSalaryAccordions(); + } + + /** + * Initialize salary accordions when they exist + */ + private initSalaryAccordions(): void { + // Initialize all accordions in the salary tab + const salaryTab = document.querySelector('swp-tab-content[data-tab="salary"]'); + if (salaryTab) { + salaryTab.querySelectorAll('swp-accordion').forEach(accordion => { + this.salaryAccordions.push(new Accordion(accordion, { singleOpen: true })); + }); + } } /**