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
This commit is contained in:
Janus C. H. Knudsen 2026-01-23 20:03:24 +01:00
parent f3c54dde35
commit a1059adf06
14 changed files with 1613 additions and 46 deletions

View file

@ -52,7 +52,24 @@
</swp-edit-section>
</swp-card>
<!-- Tillæg -->
<!-- Provision -->
<swp-card>
<swp-card-header>
<swp-card-title>@Model.LabelCommission</swp-card-title>
</swp-card-header>
<swp-edit-section>
<swp-edit-row id="card-productcommission">
<swp-edit-label>@Model.LabelProductCommission</swp-edit-label>
<input type="text" id="value-productcommission" data-type="number" value="@Model.ProductCommission" readonly>
</swp-edit-row>
<swp-edit-row id="card-servicecommission">
<swp-edit-label>@Model.LabelServiceCommission</swp-edit-label>
<input type="text" id="value-servicecommission" data-type="number" value="@Model.ServiceCommission" readonly>
</swp-edit-row>
</swp-edit-section>
</swp-card>
<!-- Tillaeg -->
<swp-card>
<swp-card-header>
<swp-card-title>@Model.LabelSupplements</swp-card-title>
@ -72,47 +89,111 @@
</swp-edit-row>
</swp-edit-section>
</swp-card>
<!-- Provision -->
<swp-card>
<swp-card-header>
<swp-card-title>@Model.LabelCommission</swp-card-title>
</swp-card-header>
<swp-edit-section>
<swp-edit-row id="card-productcommission">
<swp-edit-label>@Model.LabelProductCommission</swp-edit-label>
<input type="text" id="value-productcommission" data-type="number" value="@Model.ProductCommission" readonly>
</swp-edit-row>
<swp-edit-row id="card-servicecommission">
<swp-edit-label>@Model.LabelServiceCommission</swp-edit-label>
<input type="text" id="value-servicecommission" data-type="number" value="@Model.ServiceCommission" readonly>
</swp-edit-row>
</swp-edit-section>
</swp-card>
</swp-card-column>
<swp-card class="salary-history">
<swp-card-header>
<swp-card-title>@Model.LabelSalaryHistory</swp-card-title>
</swp-card-header>
<swp-data-table>
<swp-data-table-header>
<swp-data-table-cell>@Model.LabelPeriod</swp-data-table-cell>
<swp-data-table-cell>@Model.LabelGrossSalary</swp-data-table-cell>
<swp-data-table-cell></swp-data-table-cell>
</swp-data-table-header>
@foreach (var item in Model.SalaryHistory)
{
<swp-data-table-row>
<swp-data-table-cell>@item.Period</swp-data-table-cell>
<swp-data-table-cell class="mono">@item.GrossSalary</swp-data-table-cell>
<swp-data-table-cell><i class="ph ph-caret-right"></i></swp-data-table-cell>
</swp-data-table-row>
}
</swp-data-table>
</swp-card>
</swp-detail-grid>
<!-- Loenspecifikationer (full width accordion) -->
<swp-card class="salary-specifications">
<swp-card-header>
<swp-card-title>@Model.LabelSpecifications</swp-card-title>
</swp-card-header>
<swp-accordion id="salary-specifications-accordion">
@foreach (var spec in Model.Specifications)
{
<swp-accordion-item data-period="@spec.PeriodKey">
<swp-accordion-header>
<swp-accordion-info>
<swp-accordion-title>@spec.Period</swp-accordion-title>
<swp-accordion-meta>@spec.Weeks.Sum(w => w.NormalHours + w.OvertimeHours)t</swp-accordion-meta>
</swp-accordion-info>
<swp-accordion-summary>
<swp-summary-item>
<swp-summary-value>@spec.GrossSalaryFormatted</swp-summary-value>
<swp-summary-label>@Model.LabelTotal</swp-summary-label>
</swp-summary-item>
<swp-accordion-toggle>
<i class="ph ph-caret-down"></i>
</swp-accordion-toggle>
</swp-accordion-summary>
</swp-accordion-header>
<swp-accordion-content>
<!-- Config row -->
<swp-config-row>
<swp-config-item>
<swp-config-label>@Model.LabelNormalRate:</swp-config-label>
<swp-config-value class="mono">@spec.Config.HourlyRateFormatted</swp-config-value>
</swp-config-item>
<swp-config-item>
<swp-config-label>@Model.LabelWeeklyNorm:</swp-config-label>
<swp-config-value>@spec.Config.WeeklyHoursFormatted</swp-config-value>
</swp-config-item>
<swp-config-item>
<swp-config-label>@Model.LabelOvertimeMultiplier:</swp-config-label>
<swp-config-value>@spec.Config.OvertimeFormatted</swp-config-value>
</swp-config-item>
<swp-config-item>
<swp-config-label>@Model.LabelMinimum:</swp-config-label>
<swp-config-value class="mono">@spec.Config.MinimumFormatted</swp-config-value>
</swp-config-item>
<swp-config-item>
<swp-config-label>@Model.LabelProvision:</swp-config-label>
<swp-config-value>@spec.Config.CommissionFormatted</swp-config-value>
</swp-config-item>
</swp-config-row>
<!-- Weeks table -->
<swp-accordion-table>
<swp-data-table class="specification-weeks">
<swp-data-table-header>
<swp-data-table-cell>@Model.LabelWeek</swp-data-table-cell>
<swp-data-table-cell>Timer</swp-data-table-cell>
<swp-data-table-cell>Overtid</swp-data-table-cell>
<swp-data-table-cell>Ferie</swp-data-table-cell>
<swp-data-table-cell>Services</swp-data-table-cell>
<swp-data-table-cell>Produkter</swp-data-table-cell>
<swp-data-table-cell>Minimum</swp-data-table-cell>
<swp-data-table-cell>Provision</swp-data-table-cell>
<swp-data-table-cell>I alt</swp-data-table-cell>
</swp-data-table-header>
@foreach (var week in spec.Weeks)
{
<swp-data-table-row>
<swp-data-table-cell>Uge @week.WeekNumber</swp-data-table-cell>
<swp-data-table-cell class="mono">@week.NormalHoursFormatted</swp-data-table-cell>
<swp-data-table-cell class="mono @(week.HasOvertime ? "warning" : "")">@week.OvertimeHoursFormatted</swp-data-table-cell>
<swp-data-table-cell class="mono">@week.VacationDaysFormatted</swp-data-table-cell>
<swp-data-table-cell class="mono">@week.ServiceRevenueFormatted</swp-data-table-cell>
<swp-data-table-cell class="mono">@week.ProductRevenueFormatted</swp-data-table-cell>
<swp-data-table-cell class="mono">@week.MinimumThresholdFormatted</swp-data-table-cell>
<swp-data-table-cell class="mono @(week.HasCommission ? "highlight" : "")">@week.CommissionFormatted</swp-data-table-cell>
<swp-data-table-cell class="mono">@week.TotalPayFormatted</swp-data-table-cell>
</swp-data-table-row>
}
<swp-data-table-footer>
<swp-data-table-cell>TOTAL</swp-data-table-cell>
<swp-data-table-cell class="mono">@(spec.Weeks.Sum(w => w.NormalHours))t</swp-data-table-cell>
<swp-data-table-cell class="mono">@(spec.Weeks.Sum(w => w.OvertimeHours))t</swp-data-table-cell>
<swp-data-table-cell class="mono">@(spec.Weeks.Sum(w => w.VacationDays)) dg</swp-data-table-cell>
<swp-data-table-cell class="mono">@(spec.Weeks.Sum(w => w.ServiceRevenue).ToString("N0", System.Globalization.CultureInfo.GetCultureInfo("da-DK"))) kr</swp-data-table-cell>
<swp-data-table-cell class="mono">@(spec.Weeks.Sum(w => w.ProductRevenue).ToString("N0", System.Globalization.CultureInfo.GetCultureInfo("da-DK"))) kr</swp-data-table-cell>
<swp-data-table-cell class="mono">-</swp-data-table-cell>
<swp-data-table-cell class="mono">@(spec.Weeks.Sum(w => w.Commission).ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("da-DK"))) kr</swp-data-table-cell>
<swp-data-table-cell class="mono">@(spec.Weeks.Sum(w => w.TotalPay).ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("da-DK"))) kr</swp-data-table-cell>
</swp-data-table-footer>
</swp-data-table>
</swp-accordion-table>
<swp-accordion-footer>
<a href="/medarbejdere/loenspecifikation/@spec.PeriodKey" target="_blank" class="swp-btn secondary">
<i class="ph ph-file-text"></i>
Se lønberegning
</a>
</swp-accordion-footer>
</swp-accordion-content>
</swp-accordion-item>
}
</swp-accordion>
</swp-card>
<!-- Rates drawer -->
<div id="rates-drawer" data-drawer="lg">
<swp-drawer-header>
@ -177,7 +258,7 @@
</swp-data-row>
</swp-data-table>
<!-- Tillæg -->
<!-- Tillaeg -->
<swp-data-section>
<swp-section-label>@Model.LabelSupplements</swp-section-label>
<swp-data-table>

View file

@ -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<SalaryHistoryItem>
{
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<SalarySpecificationRoot>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
})!;
}
}
public class EmployeeDetailSalaryViewModel
{
// Salary Specifications (from JSON)
public List<SalarySpecificationDto> 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<SalarySpecificationDto> 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<SalaryWeekDto> 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;
}

View file

@ -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
}
]
}
]
}

View file

@ -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");
}
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lønspecifikation @spec.Period</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
/* Print setup */
@@page { size: A4; margin: 14mm; }
@@media print {
.no-print { display: none !important; }
.page-break { break-after: page; page-break-after: always; }
a { color: inherit; text-decoration: none; }
}
/* Base */
:root {
--ink: #0f172a;
--muted: #475569;
--line: #e2e8f0;
--soft: #f8fafc;
--accent: #0ea5e9;
--font-mono: 'JetBrains Mono', monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
color: var(--ink);
background: #fff;
}
.sheet {
max-width: 210mm;
margin: 0 auto;
padding: 20px;
}
/* Header */
.hdr {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 14px 0 10px;
border-bottom: 2px solid var(--line);
margin-bottom: 12px;
}
.brand {
display: flex;
flex-direction: column;
gap: 6px;
}
.title {
font-size: 20px;
font-weight: 800;
letter-spacing: .2px;
margin: 0;
line-height: 1.1;
}
.subtitle {
margin: 0;
color: var(--muted);
font-size: 12px;
}
.meta {
text-align: right;
min-width: 260px;
}
.meta .pill {
display: inline-block;
font-size: 12px;
padding: 6px 10px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--soft);
margin-bottom: 8px;
}
.meta .kv {
display: grid;
grid-template-columns: auto auto;
gap: 4px 12px;
justify-content: end;
font-size: 12px;
color: var(--muted);
}
.meta .kv b { color: var(--ink); font-weight: 600; }
/* Cards */
.card {
border: 1px solid var(--line);
border-radius: 14px;
overflow: hidden;
background: #fff;
margin-bottom: 12px;
}
.card .hd {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: linear-gradient(0deg, var(--soft), #fff);
border-bottom: 1px solid var(--line);
}
.card .hd h2 {
font-size: 13px;
margin: 0;
letter-spacing: .2px;
text-transform: uppercase;
color: var(--muted);
}
.card .bd { padding: 12px; }
.grid {
display: grid;
grid-template-columns: 1.2fr .8fr;
gap: 12px;
}
/* Total */
.total {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding: 14px;
border-radius: 14px;
border: 1px solid var(--line);
background: var(--soft);
margin-bottom: 12px;
}
.total .label {
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: .25px;
margin-bottom: 6px;
}
.total .big {
font-size: 28px;
font-weight: 900;
font-family: var(--font-mono);
margin: 0;
line-height: 1.05;
}
.total .big small {
font-size: 14px;
font-weight: 700;
color: var(--muted);
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
th, td {
padding: 8px;
border-bottom: 1px solid var(--line);
vertical-align: top;
}
th {
text-align: left;
color: var(--muted);
font-weight: 700;
font-size: 11px;
text-transform: uppercase;
letter-spacing: .2px;
background: var(--soft);
}
td.num, th.num { text-align: right; }
td.num { font-family: var(--font-mono); }
tr:last-child td { border-bottom: none; }
.note {
margin: 8px 0 0;
color: var(--muted);
font-size: 11px;
line-height: 1.35;
}
/* Footer */
.ftr {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--line);
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 10.5px;
color: var(--muted);
}
.avoid-break { break-inside: avoid; page-break-inside: avoid; }
/* Print button */
.print-btn {
position: fixed;
top: 20px;
right: 20px;
padding: 10px 20px;
background: #0ea5e9;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
}
.print-btn:hover { background: #0284c7; }
</style>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@@phosphor-icons/web@@2.1.2/src/regular/style.css" />
</head>
<body>
<button class="print-btn no-print" onclick="window.print()">
<i class="ph ph-printer"></i>
Udskriv
</button>
<div class="sheet">
<!-- Header -->
<div class="hdr">
<div class="brand">
<p class="title">Lønspecifikation</p>
<p class="subtitle">Periode: <b>@spec.Period</b></p>
</div>
<div class="meta">
<div class="pill">Medarbejdernr.: <b>@Model.EmployeeNumber</b></div>
<div class="kv">
<span>Medarbejder:</span><b>@Model.EmployeeName</b>
<span>Afdeling:</span><b>@Model.Department</b>
<span>Ansættelse:</span><b>@Model.EmploymentType</b>
</div>
</div>
</div>
<!-- Total -->
<section class="total avoid-break">
<div>
<div class="label">Bruttoløn (@spec.Period)</div>
<p class="big">@spec.GrossSalary.ToString("N2", culture) <small>kr</small></p>
</div>
</section>
<!-- Grid: Summary + Balances -->
<section class="grid">
<div class="card avoid-break">
<div class="hd">
<h2>Samlet lønopgørelse</h2>
</div>
<div class="bd">
<table>
<thead>
<tr>
<th>Løndel</th>
<th class="num">Beløb</th>
</tr>
</thead>
<tbody>
<tr>
<td>Grundløn inkl. overarbejde</td>
<td class="num">@((spec.BasePay + spec.OvertimePay).ToString("N2", culture)) kr</td>
</tr>
<tr>
<td>Provision i alt</td>
<td class="num">@((spec.ServiceCommission + spec.ProductCommission).ToString("N2", culture)) kr</td>
</tr>
<tr>
<td><b>Bruttoløn</b></td>
<td class="num"><b>@spec.GrossSalary.ToString("N2", culture) kr</b></td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card avoid-break">
<div class="hd">
<h2>Satser</h2>
</div>
<div class="bd">
<table>
<thead>
<tr>
<th>Type</th>
<th class="num">Værdi</th>
</tr>
</thead>
<tbody>
<tr>
<td>Timeløn</td>
<td class="num">@spec.Config.HourlyRate.ToString("N0", culture) kr</td>
</tr>
<tr>
<td>Normtid</td>
<td class="num">@spec.Config.WeeklyHours t/uge</td>
</tr>
<tr>
<td>Overtidstillæg</td>
<td class="num">@spec.Config.OvertimeFormatted</td>
</tr>
<tr>
<td>Minimum pr. time</td>
<td class="num">@spec.Config.MinimumPerHour.ToString("N0", culture) kr</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- Quick Summary -->
<section class="card avoid-break">
<div class="hd">
<h2>Hurtigt resumé</h2>
</div>
<div class="bd">
<table>
<thead>
<tr>
<th>Nøglepunkt</th>
<th class="num">Værdi</th>
</tr>
</thead>
<tbody>
<tr>
<td>Normaltimer</td>
<td class="num">@spec.Weeks.Sum(w => w.NormalHours) t</td>
</tr>
<tr>
<td>Overarbejde</td>
<td class="num">@spec.Weeks.Sum(w => w.OvertimeHours) t</td>
</tr>
<tr>
<td>Provision (services + produkter)</td>
<td class="num">@((spec.ServiceCommission + spec.ProductCommission).ToString("N2", culture)) kr</td>
</tr>
<tr>
<td>Feriedage</td>
<td class="num">@spec.Weeks.Sum(w => w.VacationDays) dage</td>
</tr>
</tbody>
</table>
</div>
</section>
<div class="ftr">
<div>Overblik</div>
<div style="text-align:right">Lønspecifikation · @spec.Period</div>
</div>
<div class="page-break"></div>
<!-- Page 2: Details -->
<section class="card">
<div class="hd">
<h2>Arbejdstid pr. uge</h2>
</div>
<div class="bd">
<table>
<thead>
<tr>
<th>Uge</th>
<th class="num">Normaltimer</th>
<th class="num">Overtid</th>
<th class="num">Services</th>
<th class="num">Produkter</th>
<th class="num">Provision</th>
<th class="num">I alt</th>
</tr>
</thead>
<tbody>
@foreach (var week in spec.Weeks)
{
<tr>
<td>Uge @week.WeekNumber</td>
<td class="num">@week.NormalHours t</td>
<td class="num">@week.OvertimeHours t</td>
<td class="num">@week.ServiceRevenue.ToString("N0", culture) kr</td>
<td class="num">@week.ProductRevenue.ToString("N0", culture) kr</td>
<td class="num">@week.Commission.ToString("N2", culture) kr</td>
<td class="num">@week.TotalPay.ToString("N2", culture) kr</td>
</tr>
}
<tr>
<td><b>I alt</b></td>
<td class="num"><b>@spec.Weeks.Sum(w => w.NormalHours) t</b></td>
<td class="num"><b>@spec.Weeks.Sum(w => w.OvertimeHours) t</b></td>
<td class="num"><b>@spec.Weeks.Sum(w => w.ServiceRevenue).ToString("N0", culture) kr</b></td>
<td class="num"><b>@spec.Weeks.Sum(w => w.ProductRevenue).ToString("N0", culture) kr</b></td>
<td class="num"><b>@spec.Weeks.Sum(w => w.Commission).ToString("N2", culture) kr</b></td>
<td class="num"><b>@spec.Weeks.Sum(w => w.TotalPay).ToString("N2", culture) kr</b></td>
</tr>
</tbody>
</table>
<p class="note">
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.
</p>
</div>
</section>
<section class="card">
<div class="hd">
<h2>Provision</h2>
</div>
<div class="bd">
<p class="note" style="margin-top:0">
<b>Services:</b> @spec.Config.ServiceCommissionPct% af omsætning over minimum (@spec.Config.MinimumPerHour.ToString("N0", culture) kr/time).<br/>
<b>Produkter:</b> @spec.Config.ProductCommissionPct% af salg.
</p>
<table>
<thead>
<tr>
<th>Uge</th>
<th class="num">Service oms.</th>
<th class="num">Produkt oms.</th>
<th class="num">Minimum</th>
<th class="num">Provision</th>
</tr>
</thead>
<tbody>
@foreach (var week in spec.Weeks)
{
<tr>
<td>Uge @week.WeekNumber</td>
<td class="num">@week.ServiceRevenue.ToString("N0", culture) kr</td>
<td class="num">@week.ProductRevenue.ToString("N0", culture) kr</td>
<td class="num">@week.MinimumThreshold.ToString("N0", culture) kr</td>
<td class="num">@week.Commission.ToString("N2", culture) kr</td>
</tr>
}
<tr>
<td><b>I alt</b></td>
<td class="num"><b>@spec.Weeks.Sum(w => w.ServiceRevenue).ToString("N0", culture) kr</b></td>
<td class="num"><b>@spec.Weeks.Sum(w => w.ProductRevenue).ToString("N0", culture) kr</b></td>
<td class="num">-</td>
<td class="num"><b>@spec.Weeks.Sum(w => w.Commission).ToString("N2", culture) kr</b></td>
</tr>
</tbody>
</table>
</div>
</section>
<div class="ftr">
<div>Detaljer</div>
<div style="text-align:right">Lønspecifikation · @spec.Period</div>
</div>
</div>
</body>
</html>

View file

@ -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<SalarySpecificationDto> LoadSalarySpecifications()
{
var jsonPath = Path.Combine(_environment.ContentRootPath, "Features", "Employees", "Data", "salarySpecificationMock.json");
var json = System.IO.File.ReadAllText(jsonPath);
var root = JsonSerializer.Deserialize<SalarySpecificationRoot>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return root?.Specifications ?? new List<SalarySpecificationDto>();
}
}