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

@ -8,7 +8,7 @@ PlanTempus is a .NET 9 web application built with ASP.NET Core Razor Pages. It u
## Related Projects
- **calpoc** = Calendar POC projekt located at `../Calendar` (TypeScript calendar component with offline-first architecture, drag-and-drop, NovaDI, EventBus). When user mentions "calpoc", refer to this folder.
- **calpoc** = Calendar POC projekt located at `../Calendar/wwwroot` (TypeScript calendar component with offline-first architecture, drag-and-drop, NovaDI, EventBus). When user mentions "calpoc", refer to this folder.
## Build and Development Commands

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>();
}
}

View file

@ -494,7 +494,19 @@
"saturdaysupplementfull": "8-21 Lørdage (udenfor arbejdstid)",
"commission": "Provisionsberegning",
"productcommissionfull": "Provision på produktsalg",
"servicecommissionfull": "Provision på servicesalg"
"servicecommissionfull": "Provision på servicesalg",
"specifications": "Lønspecifikationer",
"week": "Uge",
"normalhours": "Normtimer",
"overtimehours": "Overtid",
"vacationdays": "Feriedage",
"servicerevenue": "Services",
"productrevenue": "Produkter",
"minimumthreshold": "Minimum",
"total": "I alt",
"weeklynorm": "Normtid",
"overtimemultiplier": "Overtid",
"minimum": "Minimum"
},
"hr": {
"contractdocuments": "Kontrakt & Dokumenter",

View file

@ -494,7 +494,19 @@
"saturdaysupplementfull": "8-21 Saturdays (outside working hours)",
"commission": "Commission calculation",
"productcommissionfull": "Commission on product sales",
"servicecommissionfull": "Commission on service sales"
"servicecommissionfull": "Commission on service sales",
"specifications": "Salary specifications",
"week": "Week",
"normalhours": "Normal hours",
"overtimehours": "Overtime",
"vacationdays": "Vacation days",
"servicerevenue": "Services",
"productrevenue": "Products",
"minimumthreshold": "Minimum",
"total": "Total",
"weeklynorm": "Weekly norm",
"overtimemultiplier": "Overtime",
"minimum": "Minimum"
},
"hr": {
"contractdocuments": "Contract & Documents",

View file

@ -28,6 +28,7 @@
<link rel="stylesheet" href="~/css/waitlist.css">
<link rel="stylesheet" href="~/css/tabs.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/auth.css">
<link rel="stylesheet" href="~/css/account.css">

View file

@ -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 | Indhold |
@ -582,7 +626,8 @@ Dashed border knap til tilføjelse af elementer.
| `components.css` | Buttons, badges, cards, section-label, add-button, avatars, icon-btn, **swp-data-table** |
| `stats.css` | Stat cards, stat rows |
| `tabs.css` | Tab bar, tab content |
| `employees.css` | User info, edit forms, document lists, context styles (.employees-list, .salary-history, .stats-bookings) |
| `accordion.css` | Accordion component (swp-accordion, swp-accordion-item, expand/collapse) |
| `employees.css` | User info, edit forms, document lists, context styles (.employees-list, .salary-history, .salary-specifications, .stats-bookings) |
| `account.css` | Account/billing styles, context styles (.invoice-history) |
| `bookings.css` | Booking list items |
| `notifications.css` | Notification items |

View 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);
}

View file

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

View 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;
}

View file

@ -1,4 +1,5 @@
import { createChart } from '@sevenweirdpeople/swp-charting';
import { Accordion, initAccordions } from './accordion';
/**
* Employees Controller
@ -72,6 +73,7 @@ export class EmployeesController {
private ratesSync: RatesSyncController | null = null;
private scheduleController: ScheduleController | null = null;
private statsController: EmployeeStatsController | null = null;
private salaryAccordions: Accordion[] = [];
private listView: HTMLElement | null = null;
private detailView: HTMLElement | null = null;
@ -91,6 +93,20 @@ export class EmployeesController {
this.ratesSync = new RatesSyncController();
this.scheduleController = new ScheduleController();
this.statsController = new EmployeeStatsController();
this.initSalaryAccordions();
}
/**
* Initialize salary accordions when they exist
*/
private initSalaryAccordions(): void {
// Initialize all accordions in the salary tab
const salaryTab = document.querySelector('swp-tab-content[data-tab="salary"]');
if (salaryTab) {
salaryTab.querySelectorAll<HTMLElement>('swp-accordion').forEach(accordion => {
this.salaryAccordions.push(new Accordion(accordion, { singleOpen: true }));
});
}
}
/**