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:
parent
f3c54dde35
commit
a1059adf06
14 changed files with 1613 additions and 46 deletions
|
|
@ -8,7 +8,7 @@ PlanTempus is a .NET 9 web application built with ASP.NET Core Razor Pages. It u
|
||||||
|
|
||||||
## Related Projects
|
## 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
|
## Build and Development Commands
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,24 @@
|
||||||
</swp-edit-section>
|
</swp-edit-section>
|
||||||
</swp-card>
|
</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>
|
||||||
<swp-card-header>
|
<swp-card-header>
|
||||||
<swp-card-title>@Model.LabelSupplements</swp-card-title>
|
<swp-card-title>@Model.LabelSupplements</swp-card-title>
|
||||||
|
|
@ -72,47 +89,111 @@
|
||||||
</swp-edit-row>
|
</swp-edit-row>
|
||||||
</swp-edit-section>
|
</swp-edit-section>
|
||||||
</swp-card>
|
</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-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>
|
</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 -->
|
<!-- Rates drawer -->
|
||||||
<div id="rates-drawer" data-drawer="lg">
|
<div id="rates-drawer" data-drawer="lg">
|
||||||
<swp-drawer-header>
|
<swp-drawer-header>
|
||||||
|
|
@ -177,7 +258,7 @@
|
||||||
</swp-data-row>
|
</swp-data-row>
|
||||||
</swp-data-table>
|
</swp-data-table>
|
||||||
|
|
||||||
<!-- Tillæg -->
|
<!-- Tillaeg -->
|
||||||
<swp-data-section>
|
<swp-data-section>
|
||||||
<swp-section-label>@Model.LabelSupplements</swp-section-label>
|
<swp-section-label>@Model.LabelSupplements</swp-section-label>
|
||||||
<swp-data-table>
|
<swp-data-table>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using PlanTempus.Application.Features.Localization.Services;
|
using PlanTempus.Application.Features.Localization.Services;
|
||||||
|
|
||||||
|
|
@ -6,18 +7,23 @@ namespace PlanTempus.Application.Features.Employees.Components;
|
||||||
public class EmployeeDetailSalaryViewComponent : ViewComponent
|
public class EmployeeDetailSalaryViewComponent : ViewComponent
|
||||||
{
|
{
|
||||||
private readonly ILocalizationService _localization;
|
private readonly ILocalizationService _localization;
|
||||||
|
private readonly IWebHostEnvironment _environment;
|
||||||
|
|
||||||
public EmployeeDetailSalaryViewComponent(ILocalizationService localization)
|
public EmployeeDetailSalaryViewComponent(ILocalizationService localization, IWebHostEnvironment environment)
|
||||||
{
|
{
|
||||||
_localization = localization;
|
_localization = localization;
|
||||||
|
_environment = environment;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IViewComponentResult Invoke(string key)
|
public IViewComponentResult Invoke(string key)
|
||||||
{
|
{
|
||||||
var employee = EmployeeDetailCatalog.Get(key);
|
var employee = EmployeeDetailCatalog.Get(key);
|
||||||
|
var salaryData = LoadSalarySpecifications();
|
||||||
|
|
||||||
var model = new EmployeeDetailSalaryViewModel
|
var model = new EmployeeDetailSalaryViewModel
|
||||||
{
|
{
|
||||||
|
// Salary specifications from JSON
|
||||||
|
Specifications = salaryData.Specifications,
|
||||||
// Data
|
// Data
|
||||||
BankAccount = employee.BankAccount,
|
BankAccount = employee.BankAccount,
|
||||||
TaxCard = employee.TaxCard,
|
TaxCard = employee.TaxCard,
|
||||||
|
|
@ -89,7 +95,21 @@ public class EmployeeDetailSalaryViewComponent : ViewComponent
|
||||||
ProductCommissionValue = employee.ProductCommissionValue,
|
ProductCommissionValue = employee.ProductCommissionValue,
|
||||||
ServiceCommissionValue = employee.ServiceCommissionValue,
|
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>
|
SalaryHistory = new List<SalaryHistoryItem>
|
||||||
{
|
{
|
||||||
new() { Period = "Januar 2026", GrossSalary = "34.063,50 kr" },
|
new() { Period = "Januar 2026", GrossSalary = "34.063,50 kr" },
|
||||||
|
|
@ -102,10 +122,23 @@ public class EmployeeDetailSalaryViewComponent : ViewComponent
|
||||||
|
|
||||||
return View(model);
|
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
|
public class EmployeeDetailSalaryViewModel
|
||||||
{
|
{
|
||||||
|
// Salary Specifications (from JSON)
|
||||||
|
public List<SalarySpecificationDto> Specifications { get; init; } = new();
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
public required string BankAccount { get; init; }
|
public required string BankAccount { get; init; }
|
||||||
public required string TaxCard { get; init; }
|
public required string TaxCard { get; init; }
|
||||||
|
|
@ -160,6 +193,20 @@ public class EmployeeDetailSalaryViewModel
|
||||||
public required string LabelProductCommissionFull { get; init; }
|
public required string LabelProductCommissionFull { get; init; }
|
||||||
public required string LabelServiceCommissionFull { 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)
|
// Rate values (for drawer inputs)
|
||||||
public required string NormalRateValue { get; init; }
|
public required string NormalRateValue { get; init; }
|
||||||
public required string OvertimeRateValue { get; init; }
|
public required string OvertimeRateValue { get; init; }
|
||||||
|
|
@ -186,3 +233,70 @@ public class SalaryHistoryItem
|
||||||
public required string Period { get; init; }
|
public required string Period { get; init; }
|
||||||
public required string GrossSalary { 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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -494,7 +494,19 @@
|
||||||
"saturdaysupplementfull": "8-21 Lørdage (udenfor arbejdstid)",
|
"saturdaysupplementfull": "8-21 Lørdage (udenfor arbejdstid)",
|
||||||
"commission": "Provisionsberegning",
|
"commission": "Provisionsberegning",
|
||||||
"productcommissionfull": "Provision på produktsalg",
|
"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": {
|
"hr": {
|
||||||
"contractdocuments": "Kontrakt & Dokumenter",
|
"contractdocuments": "Kontrakt & Dokumenter",
|
||||||
|
|
|
||||||
|
|
@ -494,7 +494,19 @@
|
||||||
"saturdaysupplementfull": "8-21 Saturdays (outside working hours)",
|
"saturdaysupplementfull": "8-21 Saturdays (outside working hours)",
|
||||||
"commission": "Commission calculation",
|
"commission": "Commission calculation",
|
||||||
"productcommissionfull": "Commission on product sales",
|
"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": {
|
"hr": {
|
||||||
"contractdocuments": "Contract & Documents",
|
"contractdocuments": "Contract & Documents",
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
<link rel="stylesheet" href="~/css/waitlist.css">
|
<link rel="stylesheet" href="~/css/waitlist.css">
|
||||||
<link rel="stylesheet" href="~/css/tabs.css">
|
<link rel="stylesheet" href="~/css/tabs.css">
|
||||||
<link rel="stylesheet" href="~/css/controls.css">
|
<link rel="stylesheet" href="~/css/controls.css">
|
||||||
|
<link rel="stylesheet" href="~/css/accordion.css">
|
||||||
<link rel="stylesheet" href="~/css/cash.css">
|
<link rel="stylesheet" href="~/css/cash.css">
|
||||||
<link rel="stylesheet" href="~/css/auth.css">
|
<link rel="stylesheet" href="~/css/auth.css">
|
||||||
<link rel="stylesheet" href="~/css/account.css">
|
<link rel="stylesheet" href="~/css/account.css">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
<swp-accordion>
|
||||||
|
<swp-accordion-item>
|
||||||
|
<swp-accordion-header>
|
||||||
|
<swp-accordion-info>
|
||||||
|
<swp-accordion-title>Titel</swp-accordion-title>
|
||||||
|
<swp-accordion-meta>Subtitle</swp-accordion-meta>
|
||||||
|
</swp-accordion-info>
|
||||||
|
<swp-accordion-summary>
|
||||||
|
<swp-summary-item>
|
||||||
|
<swp-summary-value>1.234 kr</swp-summary-value>
|
||||||
|
<swp-summary-label>Label</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>
|
||||||
|
<!-- Content shown when expanded -->
|
||||||
|
</swp-accordion-content>
|
||||||
|
</swp-accordion-item>
|
||||||
|
</swp-accordion>
|
||||||
|
```
|
||||||
|
|
||||||
|
**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 Reference
|
||||||
|
|
||||||
| Fil | Indhold |
|
| 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** |
|
| `components.css` | Buttons, badges, cards, section-label, add-button, avatars, icon-btn, **swp-data-table** |
|
||||||
| `stats.css` | Stat cards, stat rows |
|
| `stats.css` | Stat cards, stat rows |
|
||||||
| `tabs.css` | Tab bar, tab content |
|
| `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) |
|
| `account.css` | Account/billing styles, context styles (.invoice-history) |
|
||||||
| `bookings.css` | Booking list items |
|
| `bookings.css` | Booking list items |
|
||||||
| `notifications.css` | Notification items |
|
| `notifications.css` | Notification items |
|
||||||
|
|
|
||||||
203
PlanTempus.Application/wwwroot/css/accordion.css
Normal file
203
PlanTempus.Application/wwwroot/css/accordion.css
Normal file
|
|
@ -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:
|
||||||
|
* <swp-accordion>
|
||||||
|
* <swp-accordion-item>
|
||||||
|
* <swp-accordion-header>
|
||||||
|
* <swp-accordion-info>
|
||||||
|
* <swp-accordion-title>Title</swp-accordion-title>
|
||||||
|
* <swp-accordion-meta>Subtitle</swp-accordion-meta>
|
||||||
|
* </swp-accordion-info>
|
||||||
|
* <swp-accordion-summary>
|
||||||
|
* <swp-summary-item>
|
||||||
|
* <swp-summary-value>Value</swp-summary-value>
|
||||||
|
* <swp-summary-label>Label</swp-summary-label>
|
||||||
|
* </swp-summary-item>
|
||||||
|
* </swp-accordion-summary>
|
||||||
|
* <swp-accordion-toggle>
|
||||||
|
* <i class="ph ph-caret-down"></i>
|
||||||
|
* </swp-accordion-toggle>
|
||||||
|
* </swp-accordion-header>
|
||||||
|
* <swp-accordion-content>
|
||||||
|
* <!-- Content -->
|
||||||
|
* </swp-accordion-content>
|
||||||
|
* </swp-accordion-item>
|
||||||
|
* </swp-accordion>
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
190
PlanTempus.Application/wwwroot/ts/modules/accordion.ts
Normal file
190
PlanTempus.Application/wwwroot/ts/modules/accordion.ts
Normal file
|
|
@ -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<HTMLElement>(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<HTMLElement>('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<HTMLElement>('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<HTMLElement>('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<HTMLElement>('swp-accordion-content');
|
||||||
|
const toggle = item.querySelector<HTMLElement>('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<HTMLElement>('swp-accordion-content');
|
||||||
|
const toggle = item.querySelector<HTMLElement>('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<HTMLElement>('swp-accordion-item:not(.expanded)').forEach(item => {
|
||||||
|
this.expand(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse all items
|
||||||
|
*/
|
||||||
|
collapseAll(): void {
|
||||||
|
this.container.querySelectorAll<HTMLElement>('swp-accordion-item.expanded').forEach(item => {
|
||||||
|
this.collapse(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all expanded items
|
||||||
|
*/
|
||||||
|
getExpanded(): HTMLElement[] {
|
||||||
|
return Array.from(this.container.querySelectorAll<HTMLElement>('swp-accordion-item.expanded'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize all accordions on the page
|
||||||
|
*/
|
||||||
|
export function initAccordions(options: AccordionOptions = {}): Accordion[] {
|
||||||
|
const accordions: Accordion[] = [];
|
||||||
|
document.querySelectorAll<HTMLElement>('swp-accordion').forEach(container => {
|
||||||
|
accordions.push(new Accordion(container, options));
|
||||||
|
});
|
||||||
|
return accordions;
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { createChart } from '@sevenweirdpeople/swp-charting';
|
import { createChart } from '@sevenweirdpeople/swp-charting';
|
||||||
|
import { Accordion, initAccordions } from './accordion';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Employees Controller
|
* Employees Controller
|
||||||
|
|
@ -72,6 +73,7 @@ export class EmployeesController {
|
||||||
private ratesSync: RatesSyncController | null = null;
|
private ratesSync: RatesSyncController | null = null;
|
||||||
private scheduleController: ScheduleController | null = null;
|
private scheduleController: ScheduleController | null = null;
|
||||||
private statsController: EmployeeStatsController | null = null;
|
private statsController: EmployeeStatsController | null = null;
|
||||||
|
private salaryAccordions: Accordion[] = [];
|
||||||
private listView: HTMLElement | null = null;
|
private listView: HTMLElement | null = null;
|
||||||
private detailView: HTMLElement | null = null;
|
private detailView: HTMLElement | null = null;
|
||||||
|
|
||||||
|
|
@ -91,6 +93,20 @@ export class EmployeesController {
|
||||||
this.ratesSync = new RatesSyncController();
|
this.ratesSync = new RatesSyncController();
|
||||||
this.scheduleController = new ScheduleController();
|
this.scheduleController = new ScheduleController();
|
||||||
this.statsController = new EmployeeStatsController();
|
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<HTMLElement>('swp-accordion').forEach(accordion => {
|
||||||
|
this.salaryAccordions.push(new Accordion(accordion, { singleOpen: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue