Various CSS work

This commit is contained in:
Janus C. H. Knudsen 2026-01-12 22:10:57 +01:00
parent ef174af0e1
commit 15579acba8
52 changed files with 8001 additions and 944 deletions

View file

@ -6,9 +6,9 @@
}
<!-- Sticky Header (Stats + Tabs) -->
<swp-cash-sticky-header>
<swp-sticky-header>
<!-- Context Stats (changes based on active tab) -->
<swp-cash-header>
<swp-header-content>
<!-- Stats for Oversigt tab -->
<swp-cash-stats data-for-tab="oversigt" class="active">
<swp-cash-stat>
@ -48,20 +48,20 @@
<swp-cash-stat-label localize="cash.stats.openedRegister">Åbnede kassen 29. dec 09:05</swp-cash-stat-label>
</swp-cash-stat>
</swp-cash-stats>
</swp-cash-header>
</swp-header-content>
<!-- Tab Bar -->
<swp-tab-bar>
<swp-tab class="active" data-tab="oversigt">
<i class="ph ph-list-checks"></i>
<span localize="cash.tabs.overview">Oversigt</span>
</swp-tab>
<swp-tab data-tab="afstemning">
<i class="ph ph-cash-register"></i>
<span localize="cash.tabs.reconciliation">Kasseafstemning</span>
</swp-tab>
</swp-tab-bar>
</swp-cash-sticky-header>
<swp-tab class="active" data-tab="oversigt">
<i class="ph ph-list-checks"></i>
<span localize="cash.tabs.overview">Oversigt</span>
</swp-tab>
<swp-tab data-tab="afstemning">
<i class="ph ph-cash-register"></i>
<span localize="cash.tabs.reconciliation">Kasseafstemning</span>
</swp-tab>
</swp-tab-bar>
</swp-sticky-header>
<!-- Tab Content: Oversigt -->
<swp-tab-content data-tab="oversigt" class="active">

View file

@ -0,0 +1,227 @@
namespace PlanTempus.Application.Features.Employees.Components;
/// <summary>
/// Shared catalog for employee detail data.
/// Used by all EmployeeDetail* ViewComponents.
/// </summary>
public static class EmployeeDetailCatalog
{
private static readonly Dictionary<string, EmployeeDetailRecord> Employees = new()
{
["employee-1"] = new EmployeeDetailRecord
{
Key = "employee-1",
Initials = "MJ",
Name = "Maria Jensen",
Email = "maria@salonbeauty.dk",
Phone = "+45 12 34 56 78",
Role = "owner",
RoleKey = "employees.roles.owner",
Status = "active",
StatusKey = "employees.status.active",
BookingsThisYear = "312",
RevenueThisYear = "245.800 kr",
Rating = "4.9",
EmployedSince = "2018",
Address = "Hovedgaden 12",
PostalCity = "2100 København Ø",
EmploymentDate = "1. januar 2018",
Position = "Ejer",
EmploymentType = "Fuldtid",
HoursPerWeek = "37",
BirthDate = "12. maj 1985",
EmergencyContact = "Peter Jensen (ægtefælle)",
EmergencyPhone = "+45 98 76 54 32",
BankAccount = "1234-5678901234",
TaxCard = "Hovedkort",
HourlyRate = "250 kr",
MonthlyFixedSalary = "45.000 kr",
Tags = new() { new("Master Stylist", "master"), new("Farvecertificeret", "cert") }
},
["employee-2"] = new EmployeeDetailRecord
{
Key = "employee-2",
Initials = "AS",
Name = "Anna Sørensen",
Email = "anna@salonbeauty.dk",
Phone = "+45 23 45 67 89",
Role = "admin",
RoleKey = "employees.roles.admin",
Status = "active",
StatusKey = "employees.status.active",
AvatarColor = "purple",
BookingsThisYear = "248",
RevenueThisYear = "186.450 kr",
Rating = "4.9",
EmployedSince = "2019",
Address = "Vestergade 15, 3. tv",
PostalCity = "8000 Aarhus C",
EmploymentDate = "1. august 2019",
Position = "Master Stylist",
EmploymentType = "Fuldtid",
HoursPerWeek = "37",
BirthDate = "15. marts 1992",
EmergencyContact = "Peter Sørensen (ægtefælle)",
EmergencyPhone = "+45 87 65 43 21",
BankAccount = "2345-6789012345",
TaxCard = "Hovedkort",
HourlyRate = "220 kr",
MonthlyFixedSalary = "38.000 kr",
Tags = new() { new("Master Stylist", "master"), new("Farvecertificeret", "cert"), new("Balayage", "cert") }
},
["employee-3"] = new EmployeeDetailRecord
{
Key = "employee-3",
Initials = "LP",
Name = "Louise Pedersen",
Email = "louise@salonbeauty.dk",
Phone = "+45 34 56 78 90",
Role = "leader",
RoleKey = "employees.roles.leader",
Status = "active",
StatusKey = "employees.status.active",
AvatarColor = "blue",
BookingsThisYear = "198",
RevenueThisYear = "156.200 kr",
Rating = "4.7",
EmployedSince = "2020",
Address = "Nørrebrogade 45",
PostalCity = "2200 København N",
EmploymentDate = "15. marts 2020",
Position = "Senior Stylist",
EmploymentType = "Fuldtid",
HoursPerWeek = "37",
BirthDate = "22. november 1988",
EmergencyContact = "Hans Pedersen (far)",
EmergencyPhone = "+45 76 54 32 10",
BankAccount = "3456-7890123456",
TaxCard = "Hovedkort",
HourlyRate = "200 kr",
MonthlyFixedSalary = "35.000 kr",
Tags = new() { new("Senior Stylist", "senior"), new("Farvecertificeret", "cert") }
},
["employee-4"] = new EmployeeDetailRecord
{
Key = "employee-4",
Initials = "KN",
Name = "Katrine Nielsen",
Email = "katrine@salonbeauty.dk",
Phone = "+45 45 67 89 01",
Role = "employee",
RoleKey = "employees.roles.employee",
Status = "active",
StatusKey = "employees.status.active",
AvatarColor = "amber",
BookingsThisYear = "165",
RevenueThisYear = "124.300 kr",
Rating = "4.8",
EmployedSince = "2021",
Address = "Frederiksberggade 28",
PostalCity = "1459 København K",
EmploymentDate = "1. juni 2021",
Position = "Stylist",
EmploymentType = "Fuldtid",
HoursPerWeek = "32",
BirthDate = "8. august 1995",
EmergencyContact = "Mette Nielsen (mor)",
EmergencyPhone = "+45 65 43 21 09",
BankAccount = "4567-8901234567",
TaxCard = "Hovedkort",
HourlyRate = "180 kr",
MonthlyFixedSalary = "32.000 kr",
Tags = new() { new("Stylist", "default") }
},
["employee-5"] = new EmployeeDetailRecord
{
Key = "employee-5",
Initials = "SH",
Name = "Sofie Hansen",
Email = "sofie@salonbeauty.dk",
Phone = "+45 56 78 90 12",
Role = "employee",
RoleKey = "employees.roles.employee",
Status = "invited",
StatusKey = "employees.status.invited",
AvatarColor = "purple",
BookingsThisYear = "0",
RevenueThisYear = "0 kr",
Rating = "-",
EmployedSince = "2025",
Address = "-",
PostalCity = "-",
EmploymentDate = "1. januar 2025",
Position = "Junior Stylist",
EmploymentType = "Fuldtid",
HoursPerWeek = "37",
BirthDate = "-",
EmergencyContact = "-",
EmergencyPhone = "-",
BankAccount = "-",
TaxCard = "-",
HourlyRate = "150 kr",
MonthlyFixedSalary = "28.000 kr",
Tags = new() { new("Junior Stylist", "junior") }
}
};
public static EmployeeDetailRecord Get(string key)
{
if (!Employees.TryGetValue(key, out var employee))
throw new KeyNotFoundException($"Employee with key '{key}' not found");
return employee;
}
public static IEnumerable<string> AllKeys => Employees.Keys;
}
/// <summary>
/// Complete employee detail record used across all detail ViewComponents.
/// </summary>
public record EmployeeDetailRecord
{
// Identity
public required string Key { get; init; }
public required string Initials { get; init; }
public required string Name { get; init; }
public string? AvatarColor { get; init; }
// Contact
public required string Email { get; init; }
public required string Phone { get; init; }
public required string Address { get; init; }
public required string PostalCity { get; init; }
// Role & Status
public required string Role { get; init; }
public required string RoleKey { get; init; }
public required string Status { get; init; }
public required string StatusKey { get; init; }
// Stats
public required string BookingsThisYear { get; init; }
public required string RevenueThisYear { get; init; }
public required string Rating { get; init; }
public required string EmployedSince { get; init; }
// Employment
public required string EmploymentDate { get; init; }
public required string Position { get; init; }
public required string EmploymentType { get; init; }
public required string HoursPerWeek { get; init; }
// Personal
public required string BirthDate { get; init; }
public required string EmergencyContact { get; init; }
public required string EmergencyPhone { get; init; }
// Salary
public required string BankAccount { get; init; }
public required string TaxCard { get; init; }
public required string HourlyRate { get; init; }
public required string MonthlyFixedSalary { get; init; }
// Tags (certifications, specialties)
public List<EmployeeTag> Tags { get; init; } = new();
}
public record EmployeeTag(string Text, string CssClass);

View file

@ -0,0 +1,151 @@
@model PlanTempus.Application.Features.Employees.Components.EmployeeDetailGeneralViewModel
<swp-detail-grid>
<!-- Left column -->
<div>
<!-- Contact Card -->
<swp-card>
<swp-section-label>@Model.LabelContact</swp-section-label>
<swp-edit-section>
<swp-edit-row>
<swp-edit-label>@Model.LabelFullName</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.Name</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.LabelEmail</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.Email</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.LabelPhone</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.Phone</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.LabelAddress</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.Address</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.LabelPostalCity</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.PostalCity</swp-edit-value>
</swp-edit-row>
</swp-edit-section>
</swp-card>
<!-- Personal Card -->
<swp-card>
<swp-section-label>@Model.LabelPersonal</swp-section-label>
<swp-edit-section>
<swp-edit-row>
<swp-edit-label>@Model.LabelBirthDate</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.BirthDate</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.LabelEmergencyContact</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.EmergencyContact</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.LabelEmergencyPhone</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.EmergencyPhone</swp-edit-value>
</swp-edit-row>
</swp-edit-section>
</swp-card>
</div>
<!-- Right column -->
<div>
<!-- Employment Card -->
<swp-card>
<swp-section-label>@Model.LabelEmployment</swp-section-label>
<swp-edit-section>
<swp-edit-row>
<swp-edit-label>@Model.LabelEmploymentDate</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.EmploymentDate</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.LabelPosition</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.Position</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.LabelEmploymentType</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.EmploymentType</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.LabelHoursPerWeek</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.HoursPerWeek</swp-edit-value>
</swp-edit-row>
</swp-edit-section>
</swp-card>
<!-- Settings Card -->
<swp-card>
<swp-section-label>@Model.LabelSettings</swp-section-label>
<swp-toggle-row>
<div>
<swp-toggle-label>@Model.SettingShowInBooking</swp-toggle-label>
<swp-toggle-description>@Model.SettingShowInBookingDesc</swp-toggle-description>
</div>
<swp-toggle-slider data-value="yes">
<swp-toggle-option>@Model.ToggleYes</swp-toggle-option>
<swp-toggle-option>@Model.ToggleNo</swp-toggle-option>
</swp-toggle-slider>
</swp-toggle-row>
<swp-toggle-row>
<div>
<swp-toggle-label>@Model.SettingSmsReminders</swp-toggle-label>
<swp-toggle-description>@Model.SettingSmsRemindersDesc</swp-toggle-description>
</div>
<swp-toggle-slider data-value="yes">
<swp-toggle-option>@Model.ToggleYes</swp-toggle-option>
<swp-toggle-option>@Model.ToggleNo</swp-toggle-option>
</swp-toggle-slider>
</swp-toggle-row>
<swp-toggle-row>
<div>
<swp-toggle-label>@Model.SettingEditCalendar</swp-toggle-label>
<swp-toggle-description>@Model.SettingEditCalendarDesc</swp-toggle-description>
</div>
<swp-toggle-slider data-value="yes">
<swp-toggle-option>@Model.ToggleYes</swp-toggle-option>
<swp-toggle-option>@Model.ToggleNo</swp-toggle-option>
</swp-toggle-slider>
</swp-toggle-row>
</swp-card>
<!-- Notifications Card -->
<swp-card>
<swp-section-label>@Model.LabelNotifications</swp-section-label>
<swp-notification-intro>@Model.NotificationsIntro</swp-notification-intro>
<swp-checkbox-list>
<swp-checkbox-row class="checked">
<swp-checkbox-box>
<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
</swp-checkbox-box>
<swp-checkbox-text>@Model.NotifOnlineBooking</swp-checkbox-text>
</swp-checkbox-row>
<swp-checkbox-row class="checked">
<swp-checkbox-box>
<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
</swp-checkbox-box>
<swp-checkbox-text>@Model.NotifManualBooking</swp-checkbox-text>
</swp-checkbox-row>
<swp-checkbox-row>
<swp-checkbox-box>
<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
</swp-checkbox-box>
<swp-checkbox-text>@Model.NotifCancellation</swp-checkbox-text>
</swp-checkbox-row>
<swp-checkbox-row>
<swp-checkbox-box>
<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
</swp-checkbox-box>
<swp-checkbox-text>@Model.NotifWaitlist</swp-checkbox-text>
</swp-checkbox-row>
<swp-checkbox-row class="checked">
<swp-checkbox-box>
<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
</swp-checkbox-box>
<swp-checkbox-text>@Model.NotifDailySummary</swp-checkbox-text>
</swp-checkbox-row>
</swp-checkbox-list>
</swp-card>
</div>
</swp-detail-grid>

View file

@ -0,0 +1,137 @@
using Microsoft.AspNetCore.Mvc;
using PlanTempus.Application.Features.Localization.Services;
namespace PlanTempus.Application.Features.Employees.Components;
public class EmployeeDetailGeneralViewComponent : ViewComponent
{
private readonly ILocalizationService _localization;
public EmployeeDetailGeneralViewComponent(ILocalizationService localization)
{
_localization = localization;
}
public IViewComponentResult Invoke(string key)
{
var employee = EmployeeDetailCatalog.Get(key);
var model = new EmployeeDetailGeneralViewModel
{
// Contact
Name = employee.Name,
Email = employee.Email,
Phone = employee.Phone,
Address = employee.Address,
PostalCity = employee.PostalCity,
// Personal
BirthDate = employee.BirthDate,
EmergencyContact = employee.EmergencyContact,
EmergencyPhone = employee.EmergencyPhone,
// Employment
EmploymentDate = employee.EmploymentDate,
Position = employee.Position,
EmploymentType = employee.EmploymentType,
HoursPerWeek = employee.HoursPerWeek,
// Labels
LabelContact = _localization.Get("employees.detail.contact"),
LabelPersonal = _localization.Get("employees.detail.personal"),
LabelEmployment = _localization.Get("employees.detail.employment"),
LabelFullName = _localization.Get("employees.detail.fullname"),
LabelEmail = _localization.Get("employees.detail.email"),
LabelPhone = _localization.Get("employees.detail.phone"),
LabelAddress = _localization.Get("employees.detail.address"),
LabelPostalCity = _localization.Get("employees.detail.postalcity"),
LabelBirthDate = _localization.Get("employees.detail.birthdate"),
LabelEmergencyContact = _localization.Get("employees.detail.emergencycontact"),
LabelEmergencyPhone = _localization.Get("employees.detail.emergencyphone"),
LabelEmploymentDate = _localization.Get("employees.detail.employmentdate"),
LabelPosition = _localization.Get("employees.detail.position"),
LabelEmploymentType = _localization.Get("employees.detail.employmenttype"),
LabelHoursPerWeek = _localization.Get("employees.detail.hoursperweek"),
// Settings
LabelSettings = _localization.Get("employees.detail.settings.label"),
SettingShowInBooking = _localization.Get("employees.detail.settings.showinbooking.label"),
SettingShowInBookingDesc = _localization.Get("employees.detail.settings.showinbooking.desc"),
SettingSmsReminders = _localization.Get("employees.detail.settings.smsreminders.label"),
SettingSmsRemindersDesc = _localization.Get("employees.detail.settings.smsreminders.desc"),
SettingEditCalendar = _localization.Get("employees.detail.settings.editcalendar.label"),
SettingEditCalendarDesc = _localization.Get("employees.detail.settings.editcalendar.desc"),
ToggleYes = _localization.Get("common.yes"),
ToggleNo = _localization.Get("common.no"),
// Notifications
LabelNotifications = _localization.Get("employees.detail.notifications.label"),
NotificationsIntro = _localization.Get("employees.detail.notifications.intro"),
NotifOnlineBooking = _localization.Get("employees.detail.notifications.onlinebooking"),
NotifManualBooking = _localization.Get("employees.detail.notifications.manualbooking"),
NotifCancellation = _localization.Get("employees.detail.notifications.cancellation"),
NotifWaitlist = _localization.Get("employees.detail.notifications.waitlist"),
NotifDailySummary = _localization.Get("employees.detail.notifications.dailysummary")
};
return View(model);
}
}
public class EmployeeDetailGeneralViewModel
{
// Contact
public required string Name { get; init; }
public required string Email { get; init; }
public required string Phone { get; init; }
public required string Address { get; init; }
public required string PostalCity { get; init; }
// Personal
public required string BirthDate { get; init; }
public required string EmergencyContact { get; init; }
public required string EmergencyPhone { get; init; }
// Employment
public required string EmploymentDate { get; init; }
public required string Position { get; init; }
public required string EmploymentType { get; init; }
public required string HoursPerWeek { get; init; }
// Labels
public required string LabelContact { get; init; }
public required string LabelPersonal { get; init; }
public required string LabelEmployment { get; init; }
public required string LabelFullName { get; init; }
public required string LabelEmail { get; init; }
public required string LabelPhone { get; init; }
public required string LabelAddress { get; init; }
public required string LabelPostalCity { get; init; }
public required string LabelBirthDate { get; init; }
public required string LabelEmergencyContact { get; init; }
public required string LabelEmergencyPhone { get; init; }
public required string LabelEmploymentDate { get; init; }
public required string LabelPosition { get; init; }
public required string LabelEmploymentType { get; init; }
public required string LabelHoursPerWeek { get; init; }
// Settings
public required string LabelSettings { get; init; }
public required string SettingShowInBooking { get; init; }
public required string SettingShowInBookingDesc { get; init; }
public required string SettingSmsReminders { get; init; }
public required string SettingSmsRemindersDesc { get; init; }
public required string SettingEditCalendar { get; init; }
public required string SettingEditCalendarDesc { get; init; }
public required string ToggleYes { get; init; }
public required string ToggleNo { get; init; }
// Notifications
public required string LabelNotifications { get; init; }
public required string NotificationsIntro { get; init; }
public required string NotifOnlineBooking { get; init; }
public required string NotifManualBooking { get; init; }
public required string NotifCancellation { get; init; }
public required string NotifWaitlist { get; init; }
public required string NotifDailySummary { get; init; }
}

View file

@ -0,0 +1,44 @@
@model PlanTempus.Application.Features.Employees.Components.EmployeeDetailHRViewModel
<swp-detail-grid>
<swp-card>
<swp-section-label>@Model.LabelDocuments</swp-section-label>
<swp-document-list>
<swp-document-item>
<i class="ph ph-file-pdf"></i>
<swp-document-name>@Model.LabelContract</swp-document-name>
<swp-document-date>15. aug 2019</swp-document-date>
</swp-document-item>
<swp-document-item>
<i class="ph ph-file-pdf"></i>
<swp-document-name>Lønaftale 2024</swp-document-name>
<swp-document-date>1. jan 2024</swp-document-date>
</swp-document-item>
</swp-document-list>
</swp-card>
<swp-card>
<swp-section-label>@Model.LabelVacation</swp-section-label>
<swp-edit-section>
<swp-edit-row>
<swp-edit-label>Optjent ferie</swp-edit-label>
<swp-edit-value>25 dage</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>Afholdt ferie</swp-edit-label>
<swp-edit-value>12 dage</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>Resterende</swp-edit-label>
<swp-edit-value>13 dage</swp-edit-value>
</swp-edit-row>
</swp-edit-section>
</swp-card>
<swp-card>
<swp-section-label>@Model.LabelNotes</swp-section-label>
<swp-notes-area contenteditable="true">
Ingen noter tilføjet endnu...
</swp-notes-area>
</swp-card>
</swp-detail-grid>

View file

@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Mvc;
using PlanTempus.Application.Features.Localization.Services;
namespace PlanTempus.Application.Features.Employees.Components;
public class EmployeeDetailHRViewComponent : ViewComponent
{
private readonly ILocalizationService _localization;
public EmployeeDetailHRViewComponent(ILocalizationService localization)
{
_localization = localization;
}
public IViewComponentResult Invoke(string key)
{
var model = new EmployeeDetailHRViewModel
{
LabelDocuments = _localization.Get("employees.detail.hr.documents"),
LabelContract = _localization.Get("employees.detail.hr.contract"),
LabelVacation = _localization.Get("employees.detail.hr.vacation"),
LabelSickLeave = _localization.Get("employees.detail.hr.sickleave"),
LabelNotes = _localization.Get("employees.detail.hr.notes")
};
return View(model);
}
}
public class EmployeeDetailHRViewModel
{
public required string LabelDocuments { get; init; }
public required string LabelContract { get; init; }
public required string LabelVacation { get; init; }
public required string LabelSickLeave { get; init; }
public required string LabelNotes { get; init; }
}

View file

@ -0,0 +1,41 @@
@model PlanTempus.Application.Features.Employees.Components.EmployeeDetailHeaderViewModel
<swp-employee-detail-header>
<swp-employee-avatar-large class="@Model.AvatarColor">@Model.Initials</swp-employee-avatar-large>
<swp-employee-info>
<swp-employee-name-row>
<swp-employee-name contenteditable="true">@Model.Name</swp-employee-name>
@if (Model.Tags.Any())
{
<swp-tags-row>
@foreach (var tag in Model.Tags)
{
<swp-tag class="@tag.CssClass">@tag.Text</swp-tag>
}
</swp-tags-row>
}
<swp-employee-status data-active="@Model.IsActive.ToString().ToLower()">
<span class="icon">●</span>
<span class="text">@Model.StatusText</span>
</swp-employee-status>
</swp-employee-name-row>
<swp-fact-boxes-inline>
<swp-fact-inline>
<swp-fact-inline-value>@Model.BookingsThisYear</swp-fact-inline-value>
<swp-fact-inline-label>@Model.LabelBookings</swp-fact-inline-label>
</swp-fact-inline>
<swp-fact-inline>
<swp-fact-inline-value>@Model.RevenueThisYear</swp-fact-inline-value>
<swp-fact-inline-label>@Model.LabelRevenue</swp-fact-inline-label>
</swp-fact-inline>
<swp-fact-inline>
<swp-fact-inline-value>@Model.Rating</swp-fact-inline-value>
<swp-fact-inline-label>@Model.LabelRating</swp-fact-inline-label>
</swp-fact-inline>
<swp-fact-inline>
<swp-fact-inline-value>@Model.EmployedSince</swp-fact-inline-value>
<swp-fact-inline-label>@Model.LabelEmployedSince</swp-fact-inline-label>
</swp-fact-inline>
</swp-fact-boxes-inline>
</swp-employee-info>
</swp-employee-detail-header>

View file

@ -0,0 +1,68 @@
using Microsoft.AspNetCore.Mvc;
using PlanTempus.Application.Features.Localization.Services;
namespace PlanTempus.Application.Features.Employees.Components;
public class EmployeeDetailHeaderViewComponent : ViewComponent
{
private readonly ILocalizationService _localization;
public EmployeeDetailHeaderViewComponent(ILocalizationService localization)
{
_localization = localization;
}
public IViewComponentResult Invoke(string key)
{
var employee = EmployeeDetailCatalog.Get(key);
var model = new EmployeeDetailHeaderViewModel
{
Initials = employee.Initials,
Name = employee.Name,
AvatarColor = employee.AvatarColor,
Role = employee.Role,
RoleText = _localization.Get(employee.RoleKey),
Status = employee.Status,
StatusText = _localization.Get(employee.StatusKey),
BookingsThisYear = employee.BookingsThisYear,
RevenueThisYear = employee.RevenueThisYear,
Rating = employee.Rating,
EmployedSince = employee.EmployedSince,
LabelBookings = _localization.Get("employees.detail.bookings"),
LabelRevenue = _localization.Get("employees.detail.revenue"),
LabelRating = _localization.Get("employees.detail.rating"),
LabelEmployedSince = _localization.Get("employees.detail.employedsince"),
Tags = employee.Tags.Select(t => new EmployeeTagViewModel { Text = t.Text, CssClass = t.CssClass }).ToList()
};
return View(model);
}
}
public class EmployeeDetailHeaderViewModel
{
public required string Initials { get; init; }
public required string Name { get; init; }
public string? AvatarColor { get; init; }
public required string Role { get; init; }
public required string RoleText { get; init; }
public required string Status { get; init; }
public required string StatusText { get; init; }
public bool IsActive => Status == "active";
public required string BookingsThisYear { get; init; }
public required string RevenueThisYear { get; init; }
public required string Rating { get; init; }
public required string EmployedSince { get; init; }
public required string LabelBookings { get; init; }
public required string LabelRevenue { get; init; }
public required string LabelRating { get; init; }
public required string LabelEmployedSince { get; init; }
public List<EmployeeTagViewModel> Tags { get; init; } = new();
}
public class EmployeeTagViewModel
{
public required string Text { get; init; }
public required string CssClass { get; init; }
}

View file

@ -0,0 +1,37 @@
@model PlanTempus.Application.Features.Employees.Components.EmployeeDetailHoursViewModel
<swp-detail-grid>
<swp-card>
<swp-section-label>@Model.LabelWeeklySchedule</swp-section-label>
<swp-schedule-grid>
<swp-schedule-row>
<swp-schedule-day>@Model.LabelMonday</swp-schedule-day>
<swp-schedule-time>09:00 - 17:00</swp-schedule-time>
</swp-schedule-row>
<swp-schedule-row>
<swp-schedule-day>@Model.LabelTuesday</swp-schedule-day>
<swp-schedule-time>09:00 - 17:00</swp-schedule-time>
</swp-schedule-row>
<swp-schedule-row>
<swp-schedule-day>@Model.LabelWednesday</swp-schedule-day>
<swp-schedule-time>09:00 - 17:00</swp-schedule-time>
</swp-schedule-row>
<swp-schedule-row>
<swp-schedule-day>@Model.LabelThursday</swp-schedule-day>
<swp-schedule-time>09:00 - 19:00</swp-schedule-time>
</swp-schedule-row>
<swp-schedule-row>
<swp-schedule-day>@Model.LabelFriday</swp-schedule-day>
<swp-schedule-time>09:00 - 16:00</swp-schedule-time>
</swp-schedule-row>
<swp-schedule-row class="off">
<swp-schedule-day>@Model.LabelSaturday</swp-schedule-day>
<swp-schedule-time>Fri</swp-schedule-time>
</swp-schedule-row>
<swp-schedule-row class="off">
<swp-schedule-day>@Model.LabelSunday</swp-schedule-day>
<swp-schedule-time>Fri</swp-schedule-time>
</swp-schedule-row>
</swp-schedule-grid>
</swp-card>
</swp-detail-grid>

View file

@ -0,0 +1,43 @@
using Microsoft.AspNetCore.Mvc;
using PlanTempus.Application.Features.Localization.Services;
namespace PlanTempus.Application.Features.Employees.Components;
public class EmployeeDetailHoursViewComponent : ViewComponent
{
private readonly ILocalizationService _localization;
public EmployeeDetailHoursViewComponent(ILocalizationService localization)
{
_localization = localization;
}
public IViewComponentResult Invoke(string key)
{
var model = new EmployeeDetailHoursViewModel
{
LabelWeeklySchedule = _localization.Get("employees.detail.hours.weekly"),
LabelMonday = _localization.Get("employees.detail.hours.monday"),
LabelTuesday = _localization.Get("employees.detail.hours.tuesday"),
LabelWednesday = _localization.Get("employees.detail.hours.wednesday"),
LabelThursday = _localization.Get("employees.detail.hours.thursday"),
LabelFriday = _localization.Get("employees.detail.hours.friday"),
LabelSaturday = _localization.Get("employees.detail.hours.saturday"),
LabelSunday = _localization.Get("employees.detail.hours.sunday")
};
return View(model);
}
}
public class EmployeeDetailHoursViewModel
{
public required string LabelWeeklySchedule { get; init; }
public required string LabelMonday { get; init; }
public required string LabelTuesday { get; init; }
public required string LabelWednesday { get; init; }
public required string LabelThursday { get; init; }
public required string LabelFriday { get; init; }
public required string LabelSaturday { get; init; }
public required string LabelSunday { get; init; }
}

View file

@ -0,0 +1,39 @@
@model PlanTempus.Application.Features.Employees.Components.EmployeeDetailSalaryViewModel
<swp-detail-grid>
<swp-card>
<swp-section-label>@Model.LabelPaymentInfo</swp-section-label>
<swp-edit-section>
<swp-edit-row>
<swp-edit-label>@Model.LabelBankAccount</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.BankAccount</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.LabelTaxCard</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.TaxCard</swp-edit-value>
</swp-edit-row>
</swp-edit-section>
</swp-card>
<swp-card>
<swp-section-label>@Model.LabelSalarySettings</swp-section-label>
<swp-edit-section>
<swp-edit-row>
<swp-edit-label>@Model.LabelHourlyRate</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.HourlyRate</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.LabelMonthlyFixed</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.MonthlyFixedSalary</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.LabelCommission</swp-edit-label>
<swp-edit-value contenteditable="true">10%</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.LabelProductCommission</swp-edit-label>
<swp-edit-value contenteditable="true">5%</swp-edit-value>
</swp-edit-row>
</swp-edit-section>
</swp-card>
</swp-detail-grid>

View file

@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Mvc;
using PlanTempus.Application.Features.Localization.Services;
namespace PlanTempus.Application.Features.Employees.Components;
public class EmployeeDetailSalaryViewComponent : ViewComponent
{
private readonly ILocalizationService _localization;
public EmployeeDetailSalaryViewComponent(ILocalizationService localization)
{
_localization = localization;
}
public IViewComponentResult Invoke(string key)
{
var employee = EmployeeDetailCatalog.Get(key);
var model = new EmployeeDetailSalaryViewModel
{
BankAccount = employee.BankAccount,
TaxCard = employee.TaxCard,
HourlyRate = employee.HourlyRate,
MonthlyFixedSalary = employee.MonthlyFixedSalary,
LabelPaymentInfo = _localization.Get("employees.detail.salary.paymentinfo"),
LabelBankAccount = _localization.Get("employees.detail.salary.bankaccount"),
LabelTaxCard = _localization.Get("employees.detail.salary.taxcard"),
LabelSalarySettings = _localization.Get("employees.detail.salary.settings"),
LabelHourlyRate = _localization.Get("employees.detail.salary.hourlyrate"),
LabelMonthlyFixed = _localization.Get("employees.detail.salary.monthlyfixed"),
LabelCommission = _localization.Get("employees.detail.salary.commission"),
LabelProductCommission = _localization.Get("employees.detail.salary.productcommission")
};
return View(model);
}
}
public class EmployeeDetailSalaryViewModel
{
public required string BankAccount { get; init; }
public required string TaxCard { get; init; }
public required string HourlyRate { get; init; }
public required string MonthlyFixedSalary { get; init; }
public required string LabelPaymentInfo { get; init; }
public required string LabelBankAccount { get; init; }
public required string LabelTaxCard { get; init; }
public required string LabelSalarySettings { get; init; }
public required string LabelHourlyRate { get; init; }
public required string LabelMonthlyFixed { get; init; }
public required string LabelCommission { get; init; }
public required string LabelProductCommission { get; init; }
}

View file

@ -0,0 +1,34 @@
@model PlanTempus.Application.Features.Employees.Components.EmployeeDetailServicesViewModel
<swp-detail-grid>
<swp-card>
<swp-section-label>@Model.LabelAssignedServices</swp-section-label>
<swp-service-list>
<swp-service-item>
<swp-service-name>Dameklip</swp-service-name>
<swp-service-duration>45 min</swp-service-duration>
<swp-service-price>450 kr</swp-service-price>
</swp-service-item>
<swp-service-item>
<swp-service-name>Herreklip</swp-service-name>
<swp-service-duration>30 min</swp-service-duration>
<swp-service-price>350 kr</swp-service-price>
</swp-service-item>
<swp-service-item>
<swp-service-name>Farvning</swp-service-name>
<swp-service-duration>90 min</swp-service-duration>
<swp-service-price>850 kr</swp-service-price>
</swp-service-item>
<swp-service-item>
<swp-service-name>Balayage</swp-service-name>
<swp-service-duration>120 min</swp-service-duration>
<swp-service-price>1.200 kr</swp-service-price>
</swp-service-item>
<swp-service-item>
<swp-service-name>Highlights</swp-service-name>
<swp-service-duration>90 min</swp-service-duration>
<swp-service-price>950 kr</swp-service-price>
</swp-service-item>
</swp-service-list>
</swp-card>
</swp-detail-grid>

View file

@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Mvc;
using PlanTempus.Application.Features.Localization.Services;
namespace PlanTempus.Application.Features.Employees.Components;
public class EmployeeDetailServicesViewComponent : ViewComponent
{
private readonly ILocalizationService _localization;
public EmployeeDetailServicesViewComponent(ILocalizationService localization)
{
_localization = localization;
}
public IViewComponentResult Invoke(string key)
{
var model = new EmployeeDetailServicesViewModel
{
LabelAssignedServices = _localization.Get("employees.detail.services.assigned")
};
return View(model);
}
}
public class EmployeeDetailServicesViewModel
{
public required string LabelAssignedServices { get; init; }
}

View file

@ -0,0 +1,25 @@
@model PlanTempus.Application.Features.Employees.Components.EmployeeDetailStatsViewModel
<swp-detail-grid>
<swp-card>
<swp-section-label>@Model.LabelPerformance</swp-section-label>
<swp-stats-row>
<swp-stat-card class="teal">
<swp-stat-value>@Model.BookingsThisYear</swp-stat-value>
<swp-stat-label>@Model.LabelBookingsThisYear</swp-stat-label>
</swp-stat-card>
<swp-stat-card class="purple">
<swp-stat-value>@Model.RevenueThisYear</swp-stat-value>
<swp-stat-label>@Model.LabelRevenueThisYear</swp-stat-label>
</swp-stat-card>
<swp-stat-card class="amber">
<swp-stat-value>@Model.Rating</swp-stat-value>
<swp-stat-label>@Model.LabelAvgRating</swp-stat-label>
</swp-stat-card>
<swp-stat-card>
<swp-stat-value>87%</swp-stat-value>
<swp-stat-label>@Model.LabelOccupancy</swp-stat-label>
</swp-stat-card>
</swp-stats-row>
</swp-card>
</swp-detail-grid>

View file

@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Mvc;
using PlanTempus.Application.Features.Localization.Services;
namespace PlanTempus.Application.Features.Employees.Components;
public class EmployeeDetailStatsViewComponent : ViewComponent
{
private readonly ILocalizationService _localization;
public EmployeeDetailStatsViewComponent(ILocalizationService localization)
{
_localization = localization;
}
public IViewComponentResult Invoke(string key)
{
var employee = EmployeeDetailCatalog.Get(key);
var model = new EmployeeDetailStatsViewModel
{
BookingsThisYear = employee.BookingsThisYear,
RevenueThisYear = employee.RevenueThisYear,
Rating = employee.Rating,
LabelPerformance = _localization.Get("employees.detail.stats.performance"),
LabelBookingsThisYear = _localization.Get("employees.detail.stats.bookingsyear"),
LabelRevenueThisYear = _localization.Get("employees.detail.stats.revenueyear"),
LabelAvgRating = _localization.Get("employees.detail.stats.avgrating"),
LabelOccupancy = _localization.Get("employees.detail.stats.occupancy")
};
return View(model);
}
}
public class EmployeeDetailStatsViewModel
{
public required string BookingsThisYear { get; init; }
public required string RevenueThisYear { get; init; }
public required string Rating { get; init; }
public required string LabelPerformance { get; init; }
public required string LabelBookingsThisYear { get; init; }
public required string LabelRevenueThisYear { get; init; }
public required string LabelAvgRating { get; init; }
public required string LabelOccupancy { get; init; }
}

View file

@ -0,0 +1,74 @@
@model PlanTempus.Application.Features.Employees.Components.EmployeeDetailViewViewModel
<swp-employee-detail-view id="employee-detail-view" data-employee="@Model.EmployeeKey">
<!-- Sticky Header (generic from page.css) -->
<swp-sticky-header>
<swp-header-content>
<!-- Page Header with Back Button -->
<swp-page-header>
<swp-page-title>
<swp-back-link data-employee-back>
<i class="ph ph-arrow-left"></i>
@Model.BackText
</swp-back-link>
</swp-page-title>
<swp-page-actions>
<swp-btn class="primary">
<i class="ph ph-floppy-disk"></i>
@Model.SaveButtonText
</swp-btn>
</swp-page-actions>
</swp-page-header>
<!-- Employee Header -->
@await Component.InvokeAsync("EmployeeDetailHeader", Model.EmployeeKey)
</swp-header-content>
<!-- Tabs (outside header-content, inside sticky-header) -->
<swp-tab-bar>
<swp-tab class="active" data-tab="general">@Model.TabGeneral</swp-tab>
<swp-tab data-tab="hours">@Model.TabHours</swp-tab>
<swp-tab data-tab="services">@Model.TabServices</swp-tab>
<swp-tab data-tab="salary">@Model.TabSalary</swp-tab>
<swp-tab data-tab="hr">@Model.TabHR</swp-tab>
<swp-tab data-tab="stats">@Model.TabStats</swp-tab>
</swp-tab-bar>
</swp-sticky-header>
<!-- Tab Contents -->
<swp-tab-content data-tab="general" class="active">
<swp-page-container>
@await Component.InvokeAsync("EmployeeDetailGeneral", Model.EmployeeKey)
</swp-page-container>
</swp-tab-content>
<swp-tab-content data-tab="hours">
<swp-page-container>
@await Component.InvokeAsync("EmployeeDetailHours", Model.EmployeeKey)
</swp-page-container>
</swp-tab-content>
<swp-tab-content data-tab="services">
<swp-page-container>
@await Component.InvokeAsync("EmployeeDetailServices", Model.EmployeeKey)
</swp-page-container>
</swp-tab-content>
<swp-tab-content data-tab="salary">
<swp-page-container>
@await Component.InvokeAsync("EmployeeDetailSalary", Model.EmployeeKey)
</swp-page-container>
</swp-tab-content>
<swp-tab-content data-tab="hr">
<swp-page-container>
@await Component.InvokeAsync("EmployeeDetailHR", Model.EmployeeKey)
</swp-page-container>
</swp-tab-content>
<swp-tab-content data-tab="stats">
<swp-page-container>
@await Component.InvokeAsync("EmployeeDetailStats", Model.EmployeeKey)
</swp-page-container>
</swp-tab-content>
</swp-employee-detail-view>

View file

@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Mvc;
using PlanTempus.Application.Features.Localization.Services;
namespace PlanTempus.Application.Features.Employees.Components;
public class EmployeeDetailViewViewComponent : ViewComponent
{
private readonly ILocalizationService _localization;
public EmployeeDetailViewViewComponent(ILocalizationService localization)
{
_localization = localization;
}
public IViewComponentResult Invoke(string key)
{
var employee = EmployeeDetailCatalog.Get(key);
var model = new EmployeeDetailViewViewModel
{
EmployeeKey = employee.Key,
BackText = _localization.Get("employees.detail.back"),
SaveButtonText = _localization.Get("employees.detail.save"),
TabGeneral = _localization.Get("employees.detail.tabs.general"),
TabHours = _localization.Get("employees.detail.tabs.hours"),
TabServices = _localization.Get("employees.detail.tabs.services"),
TabSalary = _localization.Get("employees.detail.tabs.salary"),
TabHR = _localization.Get("employees.detail.tabs.hr"),
TabStats = _localization.Get("employees.detail.tabs.stats")
};
return View(model);
}
}
public class EmployeeDetailViewViewModel
{
public required string EmployeeKey { get; init; }
public required string BackText { get; init; }
public required string SaveButtonText { get; init; }
public required string TabGeneral { get; init; }
public required string TabHours { get; init; }
public required string TabServices { get; init; }
public required string TabSalary { get; init; }
public required string TabHR { get; init; }
public required string TabStats { get; init; }
}

View file

@ -0,0 +1,25 @@
@model PlanTempus.Application.Features.Employees.Components.EmployeeRowViewModel
<swp-employee-row data-employee-detail="@Model.Key">
<swp-employee-cell>
<swp-user-info>
<swp-user-avatar class="@Model.AvatarColor">@Model.Initials</swp-user-avatar>
<swp-user-details>
<swp-user-name>@Model.Name</swp-user-name>
<swp-user-email>@Model.Email</swp-user-email>
</swp-user-details>
</swp-user-info>
</swp-employee-cell>
<swp-employee-cell>
<swp-status-badge class="@Model.Role">@Model.RoleText</swp-status-badge>
</swp-employee-cell>
<swp-employee-cell>
<swp-status-badge class="@Model.Status">@Model.StatusText</swp-status-badge>
</swp-employee-cell>
<swp-employee-cell>@Model.LastActive</swp-employee-cell>
<swp-employee-cell>
<swp-row-toggle>
<i class="ph ph-caret-right"></i>
</swp-row-toggle>
</swp-employee-cell>
</swp-employee-row>

View file

@ -0,0 +1,145 @@
using Microsoft.AspNetCore.Mvc;
using PlanTempus.Application.Features.Localization.Services;
namespace PlanTempus.Application.Features.Employees.Components;
public class EmployeeRowViewComponent : ViewComponent
{
private readonly ILocalizationService _localization;
public EmployeeRowViewComponent(ILocalizationService localization)
{
_localization = localization;
}
public IViewComponentResult Invoke(string key)
{
var model = EmployeeRowCatalog.Get(key, _localization);
return View(model);
}
}
public class EmployeeRowViewModel
{
public required string Key { get; init; }
public required string Initials { get; init; }
public required string Name { get; init; }
public required string Email { get; init; }
public required string Role { get; init; }
public required string RoleText { get; init; }
public required string Status { get; init; }
public required string StatusText { get; init; }
public required string LastActive { get; init; }
public string? AvatarColor { get; init; }
public bool IsOwner { get; init; }
public bool IsInvited { get; init; }
}
internal class EmployeeRowData
{
public required string Key { get; init; }
public required string Initials { get; init; }
public required string Name { get; init; }
public required string Email { get; init; }
public required string Role { get; init; }
public required string RoleKey { get; init; }
public required string Status { get; init; }
public required string StatusKey { get; init; }
public required string LastActive { get; init; }
public string? AvatarColor { get; init; }
}
public static class EmployeeRowCatalog
{
private static readonly Dictionary<string, EmployeeRowData> Employees = new()
{
["employee-1"] = new EmployeeRowData
{
Key = "employee-1",
Initials = "MJ",
Name = "Maria Jensen",
Email = "maria@salonbeauty.dk",
Role = "owner",
RoleKey = "employees.roles.owner",
Status = "active",
StatusKey = "employees.status.active",
LastActive = "I dag, 14:32"
},
["employee-2"] = new EmployeeRowData
{
Key = "employee-2",
Initials = "AS",
Name = "Anna Sørensen",
Email = "anna@salonbeauty.dk",
Role = "admin",
RoleKey = "employees.roles.admin",
Status = "active",
StatusKey = "employees.status.active",
LastActive = "I dag, 12:15",
AvatarColor = "purple"
},
["employee-3"] = new EmployeeRowData
{
Key = "employee-3",
Initials = "LP",
Name = "Louise Pedersen",
Email = "louise@salonbeauty.dk",
Role = "leader",
RoleKey = "employees.roles.leader",
Status = "active",
StatusKey = "employees.status.active",
LastActive = "I går, 17:45",
AvatarColor = "blue"
},
["employee-4"] = new EmployeeRowData
{
Key = "employee-4",
Initials = "KN",
Name = "Katrine Nielsen",
Email = "katrine@salonbeauty.dk",
Role = "employee",
RoleKey = "employees.roles.employee",
Status = "active",
StatusKey = "employees.status.active",
LastActive = "27. dec, 09:30",
AvatarColor = "amber"
},
["employee-5"] = new EmployeeRowData
{
Key = "employee-5",
Initials = "SH",
Name = "Sofie Hansen",
Email = "sofie@salonbeauty.dk",
Role = "employee",
RoleKey = "employees.roles.employee",
Status = "invited",
StatusKey = "employees.status.invited",
LastActive = "-",
AvatarColor = "purple"
}
};
public static EmployeeRowViewModel Get(string key, ILocalizationService localization)
{
if (!Employees.TryGetValue(key, out var employee))
throw new KeyNotFoundException($"Employee with key '{key}' not found");
return new EmployeeRowViewModel
{
Key = employee.Key,
Initials = employee.Initials,
Name = employee.Name,
Email = employee.Email,
Role = employee.Role,
RoleText = localization.Get(employee.RoleKey),
Status = employee.Status,
StatusText = localization.Get(employee.StatusKey),
LastActive = employee.LastActive,
AvatarColor = employee.AvatarColor,
IsOwner = employee.Role == "owner",
IsInvited = employee.Status == "invited"
};
}
public static IEnumerable<string> AllKeys => Employees.Keys;
}

View file

@ -0,0 +1,6 @@
@model PlanTempus.Application.Features.Employees.Components.EmployeeStatCardViewModel
<swp-stat-card data-key="@Model.Key" class="@Model.Variant">
<swp-stat-value>@Model.Value</swp-stat-value>
<swp-stat-label>@Model.Label</swp-stat-label>
</swp-stat-card>

View file

@ -0,0 +1,80 @@
using Microsoft.AspNetCore.Mvc;
using PlanTempus.Application.Features.Localization.Services;
namespace PlanTempus.Application.Features.Employees.Components;
public class EmployeeStatCardViewComponent : ViewComponent
{
private readonly ILocalizationService _localization;
public EmployeeStatCardViewComponent(ILocalizationService localization)
{
_localization = localization;
}
public IViewComponentResult Invoke(string key)
{
var model = EmployeeStatCardCatalog.Get(key, _localization);
return View(model);
}
}
public class EmployeeStatCardViewModel
{
public required string Key { get; init; }
public required string Value { get; init; }
public required string Label { get; init; }
public string? Variant { get; init; }
}
internal class EmployeeStatCardData
{
public required string Key { get; init; }
public required string Value { get; init; }
public required string LabelKey { get; init; }
public string? Variant { get; init; }
}
public static class EmployeeStatCardCatalog
{
private static readonly Dictionary<string, EmployeeStatCardData> Cards = new()
{
["active-employees"] = new EmployeeStatCardData
{
Key = "active-employees",
Value = "4",
LabelKey = "employees.stats.activeEmployees",
Variant = "teal"
},
["pending-invitations"] = new EmployeeStatCardData
{
Key = "pending-invitations",
Value = "1",
LabelKey = "employees.stats.pendingInvitations",
Variant = "amber"
},
["roles-defined"] = new EmployeeStatCardData
{
Key = "roles-defined",
Value = "4",
LabelKey = "employees.stats.rolesDefined",
Variant = "purple"
}
};
public static EmployeeStatCardViewModel Get(string key, ILocalizationService localization)
{
if (!Cards.TryGetValue(key, out var card))
throw new KeyNotFoundException($"EmployeeStatCard with key '{key}' not found");
return new EmployeeStatCardViewModel
{
Key = card.Key,
Value = card.Value,
Label = localization.Get(card.LabelKey),
Variant = card.Variant
};
}
public static IEnumerable<string> AllKeys => Cards.Keys;
}

View file

@ -0,0 +1,34 @@
@model PlanTempus.Application.Features.Employees.Components.EmployeeTableViewModel
<swp-users-header>
<swp-users-count>
<strong>@Model.CurrentCount af @Model.MaxCount</strong> @Model.CountLabel
<swp-users-progress>
<swp-users-progress-bar style="width: @Model.ProgressPercent.ToString("F1", System.Globalization.CultureInfo.InvariantCulture)%"></swp-users-progress-bar>
</swp-users-progress>
</swp-users-count>
<swp-btn class="primary">
<i class="ph ph-user-plus"></i>
@Model.InviteButtonText
</swp-btn>
</swp-users-header>
<swp-employee-table-card>
<swp-employee-table>
<swp-employee-table-header>
<swp-employee-row>
<swp-employee-cell>@Model.ColumnUser</swp-employee-cell>
<swp-employee-cell>@Model.ColumnRole</swp-employee-cell>
<swp-employee-cell>@Model.ColumnStatus</swp-employee-cell>
<swp-employee-cell>@Model.ColumnLastActive</swp-employee-cell>
<swp-employee-cell></swp-employee-cell>
</swp-employee-row>
</swp-employee-table-header>
<swp-employee-table-body>
@foreach (var employeeKey in Model.EmployeeKeys)
{
@await Component.InvokeAsync("EmployeeRow", employeeKey)
}
</swp-employee-table-body>
</swp-employee-table>
</swp-employee-table-card>

View file

@ -0,0 +1,77 @@
using Microsoft.AspNetCore.Mvc;
using PlanTempus.Application.Features.Localization.Services;
namespace PlanTempus.Application.Features.Employees.Components;
public class EmployeeTableViewComponent : ViewComponent
{
private readonly ILocalizationService _localization;
public EmployeeTableViewComponent(ILocalizationService localization)
{
_localization = localization;
}
public IViewComponentResult Invoke(string key)
{
var model = EmployeeTableCatalog.Get(key, _localization);
return View(model);
}
}
public class EmployeeTableViewModel
{
public required string Key { get; init; }
public required int CurrentCount { get; init; }
public required int MaxCount { get; init; }
public required string CountLabel { get; init; }
public required string InviteButtonText { get; init; }
public required string ColumnUser { get; init; }
public required string ColumnRole { get; init; }
public required string ColumnStatus { get; init; }
public required string ColumnLastActive { get; init; }
public required IReadOnlyList<string> EmployeeKeys { get; init; }
public double ProgressPercent => MaxCount > 0 ? (double)CurrentCount / MaxCount * 100 : 0;
}
internal class EmployeeTableData
{
public required string Key { get; init; }
public required int CurrentCount { get; init; }
public required int MaxCount { get; init; }
public required IReadOnlyList<string> EmployeeKeys { get; init; }
}
public static class EmployeeTableCatalog
{
private static readonly Dictionary<string, EmployeeTableData> Tables = new()
{
["all-employees"] = new EmployeeTableData
{
Key = "all-employees",
CurrentCount = 5,
MaxCount = 8,
EmployeeKeys = ["employee-1", "employee-2", "employee-3", "employee-4", "employee-5"]
}
};
public static EmployeeTableViewModel Get(string key, ILocalizationService localization)
{
if (!Tables.TryGetValue(key, out var table))
throw new KeyNotFoundException($"EmployeeTable with key '{key}' not found");
return new EmployeeTableViewModel
{
Key = table.Key,
CurrentCount = table.CurrentCount,
MaxCount = table.MaxCount,
CountLabel = localization.Get("employees.users.count"),
InviteButtonText = localization.Get("employees.users.inviteUser"),
ColumnUser = localization.Get("employees.users.columns.user"),
ColumnRole = localization.Get("employees.users.columns.role"),
ColumnStatus = localization.Get("employees.users.columns.status"),
ColumnLastActive = localization.Get("employees.users.columns.lastActive"),
EmployeeKeys = table.EmployeeKeys
};
}
}

View file

@ -0,0 +1,41 @@
@model PlanTempus.Application.Features.Employees.Components.PermissionsMatrixViewModel
<swp-permissions-matrix>
<table>
<thead>
<tr>
<th localize="employees.permissions.title">Rettighed</th>
@foreach (var role in Model.Roles)
{
<th>@role.Name</th>
}
</tr>
</thead>
<tbody>
@foreach (var permission in Model.Permissions)
{
<tr>
<td>
<span class="permission-name">
<i class="ph @permission.Icon"></i>
@permission.Name
</span>
</td>
@foreach (var role in Model.Roles)
{
<td>
@if (permission.RoleAccess.TryGetValue(role.Key, out var hasAccess) && hasAccess)
{
<i class="ph ph-check-circle check"></i>
}
else
{
<i class="ph ph-minus no-access"></i>
}
</td>
}
</tr>
}
</tbody>
</table>
</swp-permissions-matrix>

View file

@ -0,0 +1,158 @@
using Microsoft.AspNetCore.Mvc;
using PlanTempus.Application.Features.Localization.Services;
namespace PlanTempus.Application.Features.Employees.Components;
public class PermissionsMatrixViewComponent : ViewComponent
{
private readonly ILocalizationService _localization;
public PermissionsMatrixViewComponent(ILocalizationService localization)
{
_localization = localization;
}
public IViewComponentResult Invoke(string key)
{
var model = PermissionsMatrixCatalog.Get(key, _localization);
return View(model);
}
}
public class PermissionsMatrixViewModel
{
public required string Key { get; init; }
public required IReadOnlyList<RoleHeader> Roles { get; init; }
public required IReadOnlyList<PermissionRow> Permissions { get; init; }
}
public class RoleHeader
{
public required string Key { get; init; }
public required string Name { get; init; }
}
public class PermissionRow
{
public required string Key { get; init; }
public required string Name { get; init; }
public required string Icon { get; init; }
public required IReadOnlyDictionary<string, bool> RoleAccess { get; init; }
}
internal class PermissionsMatrixData
{
public required string Key { get; init; }
public required IReadOnlyList<string> RoleKeys { get; init; }
public required IReadOnlyList<PermissionData> Permissions { get; init; }
}
internal class PermissionData
{
public required string Key { get; init; }
public required string NameKey { get; init; }
public required string Icon { get; init; }
public required IReadOnlyDictionary<string, bool> RoleAccess { get; init; }
}
public static class PermissionsMatrixCatalog
{
private static readonly Dictionary<string, string> RoleNameKeys = new()
{
["owner"] = "employees.roles.owner",
["admin"] = "employees.roles.admin",
["leader"] = "employees.roles.leader",
["employee"] = "employees.roles.employee"
};
private static readonly Dictionary<string, PermissionsMatrixData> Matrices = new()
{
["default"] = new PermissionsMatrixData
{
Key = "default",
RoleKeys = ["owner", "admin", "leader", "employee"],
Permissions =
[
new PermissionData
{
Key = "calendar",
NameKey = "employees.permissions.calendar",
Icon = "ph-calendar",
RoleAccess = new Dictionary<string, bool>
{
["owner"] = true,
["admin"] = true,
["leader"] = true,
["employee"] = true
}
},
new PermissionData
{
Key = "employees",
NameKey = "employees.permissions.employees",
Icon = "ph-users",
RoleAccess = new Dictionary<string, bool>
{
["owner"] = true,
["admin"] = true,
["leader"] = true,
["employee"] = false
}
},
new PermissionData
{
Key = "customers",
NameKey = "employees.permissions.customers",
Icon = "ph-address-book",
RoleAccess = new Dictionary<string, bool>
{
["owner"] = true,
["admin"] = true,
["leader"] = true,
["employee"] = true
}
},
new PermissionData
{
Key = "reports",
NameKey = "employees.permissions.reports",
Icon = "ph-chart-bar",
RoleAccess = new Dictionary<string, bool>
{
["owner"] = true,
["admin"] = true,
["leader"] = false,
["employee"] = false
}
}
]
}
};
public static PermissionsMatrixViewModel Get(string key, ILocalizationService localization)
{
if (!Matrices.TryGetValue(key, out var matrix))
throw new KeyNotFoundException($"PermissionsMatrix with key '{key}' not found");
var roles = matrix.RoleKeys.Select(roleKey => new RoleHeader
{
Key = roleKey,
Name = localization.Get(RoleNameKeys[roleKey])
}).ToList();
var permissions = matrix.Permissions.Select(p => new PermissionRow
{
Key = p.Key,
Name = localization.Get(p.NameKey),
Icon = p.Icon,
RoleAccess = p.RoleAccess
}).ToList();
return new PermissionsMatrixViewModel
{
Key = matrix.Key,
Roles = roles,
Permissions = permissions
};
}
}

View file

@ -0,0 +1,57 @@
@page "/medarbejdere"
@using PlanTempus.Application.Features.Employees.Components
@model PlanTempus.Application.Features.Employees.Pages.IndexModel
@{
ViewData["Title"] = "Medarbejdere";
}
<!-- List View (default) -->
<swp-employees-list-view id="employees-list-view">
<!-- Sticky Header (Header + Tabs) -->
<swp-sticky-header>
<!-- Header with page title and stats (has border-bottom) -->
<swp-header-content>
<swp-page-header>
<swp-page-title>
<h1 localize="employees.title">Medarbejdere</h1>
<p localize="employees.subtitle">Administrer brugere, roller og rettigheder</p>
</swp-page-title>
</swp-page-header>
<swp-stats-row>
@await Component.InvokeAsync("EmployeeStatCard", "active-employees")
@await Component.InvokeAsync("EmployeeStatCard", "pending-invitations")
@await Component.InvokeAsync("EmployeeStatCard", "roles-defined")
</swp-stats-row>
</swp-header-content>
<!-- Tab bar (outside header, so line is above tabs) -->
<swp-tab-bar>
<swp-tab class="active" data-tab="users">
<i class="ph ph-users"></i>
<span localize="employees.tabs.users">Brugere</span>
</swp-tab>
<swp-tab data-tab="roles">
<i class="ph ph-shield-check"></i>
<span localize="employees.tabs.roles">Roller</span>
</swp-tab>
</swp-tab-bar>
</swp-sticky-header>
<!-- Tab: Users -->
<swp-tab-content data-tab="users" class="active">
<swp-page-container>
@await Component.InvokeAsync("EmployeeTable", "all-employees")
</swp-page-container>
</swp-tab-content>
<!-- Tab: Roles -->
<swp-tab-content data-tab="roles">
<swp-page-container>
@await Component.InvokeAsync("PermissionsMatrix", "default")
</swp-page-container>
</swp-tab-content>
</swp-employees-list-view>
<!-- Detail View (hidden by default, shown when row clicked) -->
@await Component.InvokeAsync("EmployeeDetailView", "employee-1")

View file

@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace PlanTempus.Application.Features.Employees.Pages;
public class IndexModel : PageModel
{
public void OnGet()
{
}
}

View file

@ -29,7 +29,9 @@
"to": "Til",
"all": "Alle",
"reset": "Nulstil",
"status": "Status"
"status": "Status",
"yes": "Ja",
"no": "Nej"
},
"sidebar": {
"lockScreen": "Lås skærm",
@ -216,5 +218,144 @@
"pending": "Afventer",
"overdue": "Forfalden"
}
},
"employees": {
"title": "Medarbejdere",
"subtitle": "Administrer brugere, roller og rettigheder",
"stats": {
"activeEmployees": "Aktive medarbejdere",
"pendingInvitations": "Afventer invitation",
"rolesDefined": "Roller defineret"
},
"tabs": {
"users": "Brugere",
"roles": "Roller"
},
"users": {
"count": "brugere",
"inviteUser": "Inviter bruger",
"columns": {
"user": "Bruger",
"role": "Rolle",
"status": "Status",
"lastActive": "Sidst aktiv"
}
},
"roles": {
"owner": "Ejer",
"admin": "Admin",
"leader": "Leder",
"employee": "Medarbejder"
},
"status": {
"active": "Aktiv",
"invited": "Invitation sendt"
},
"permissions": {
"title": "Rettighed",
"calendar": "Kalender",
"employees": "Medarbejdere",
"customers": "Kunder",
"reports": "Rapporter & Økonomi"
},
"actions": {
"edit": "Rediger",
"remove": "Fjern bruger",
"resend": "Send invitation igen",
"cancel": "Annuller invitation"
},
"detail": {
"title": "Medarbejderdetaljer",
"back": "Tilbage til medarbejdere",
"save": "Gem ændringer",
"tabs": {
"general": "Generelt",
"hours": "Arbejdstid",
"services": "Services",
"salary": "Løn",
"hr": "HR",
"stats": "Statistik"
},
"contact": "Kontaktoplysninger",
"personal": "Personlige oplysninger",
"employment": "Ansættelse",
"fullname": "Fulde navn",
"email": "E-mail",
"phone": "Telefon",
"address": "Adresse",
"postalcity": "Postnr. & By",
"birthdate": "Fødselsdato",
"emergencycontact": "Nødkontakt",
"emergencyphone": "Nødkontakt tlf.",
"employmentdate": "Ansættelsesdato",
"position": "Stilling",
"employmenttype": "Ansættelsestype",
"hoursperweek": "Timer/uge",
"bookings": "bookinger i år",
"revenue": "omsætning i år",
"rating": "rating",
"employedsince": "ansat siden",
"hours": {
"weekly": "Ugentlig arbejdstid",
"monday": "Mandag",
"tuesday": "Tirsdag",
"wednesday": "Onsdag",
"thursday": "Torsdag",
"friday": "Fredag",
"saturday": "Lørdag",
"sunday": "Søndag"
},
"services": {
"assigned": "Tildelte services"
},
"salary": {
"paymentinfo": "Betalingsoplysninger",
"bankaccount": "Bankkonto",
"taxcard": "Skattekort",
"settings": "Lønindstillinger",
"hourlyrate": "Timesats",
"monthlyfixed": "Fast månedsløn",
"commission": "Provision (services)",
"productcommission": "Provision (produkter)"
},
"hr": {
"documents": "Dokumenter",
"contract": "Ansættelseskontrakt",
"vacation": "Ferie",
"sickleave": "Sygefravær",
"notes": "Noter"
},
"stats": {
"performance": "Performance",
"bookingsyear": "Bookinger i år",
"revenueyear": "Omsætning i år",
"avgrating": "Gns. rating",
"occupancy": "Belægningsgrad"
},
"settings": {
"label": "Indstillinger",
"showinbooking": {
"label": "Vis i online booking",
"desc": "Kunder kan vælge denne medarbejder"
},
"smsreminders": {
"label": "Modtag SMS-påmindelser",
"desc": "Få besked om nye bookinger"
},
"editcalendar": {
"label": "Kan redigere egen kalender",
"desc": "Tillad ændringer i egne bookinger"
}
},
"notifications": {
"label": "Notifikationer",
"intro": "Vælg hvilke email-notifikationer medarbejderen skal modtage.",
"onlinebooking": "Modtag email ved online booking",
"manualbooking": "Modtag email ved manuel booking",
"cancellation": "Modtag email ved aflysning",
"waitlist": "Modtag email ved opskrivning til venteliste",
"dailysummary": "Modtag daglig oversigt over morgendagens bookinger"
}
}
}
}

View file

@ -29,7 +29,9 @@
"to": "To",
"all": "All",
"reset": "Reset",
"status": "Status"
"status": "Status",
"yes": "Yes",
"no": "No"
},
"sidebar": {
"lockScreen": "Lock screen",
@ -216,5 +218,107 @@
"pending": "Pending",
"overdue": "Overdue"
}
},
"employees": {
"title": "Employees",
"subtitle": "Manage users, roles and permissions",
"stats": {
"activeEmployees": "Active employees",
"pendingInvitations": "Pending invitations",
"rolesDefined": "Roles defined"
},
"tabs": {
"users": "Users",
"roles": "Roles"
},
"users": {
"count": "users",
"inviteUser": "Invite user",
"columns": {
"user": "User",
"role": "Role",
"status": "Status",
"lastActive": "Last active"
}
},
"roles": {
"owner": "Owner",
"admin": "Admin",
"leader": "Manager",
"employee": "Employee"
},
"status": {
"active": "Active",
"invited": "Invitation sent"
},
"permissions": {
"title": "Permission",
"calendar": "Calendar",
"employees": "Employees",
"customers": "Customers",
"reports": "Reports & Finance"
},
"actions": {
"edit": "Edit",
"remove": "Remove user",
"resend": "Resend invitation",
"cancel": "Cancel invitation"
},
"detail": {
"title": "Employee details",
"back": "Back to employees",
"save": "Save changes",
"tabs": {
"general": "General",
"hours": "Working hours",
"services": "Services",
"salary": "Salary",
"hr": "HR",
"stats": "Statistics"
},
"contact": "Contact information",
"personal": "Personal information",
"employment": "Employment",
"fullname": "Full name",
"email": "Email",
"phone": "Phone",
"address": "Address",
"postalcity": "Postal code & City",
"birthdate": "Date of birth",
"emergencycontact": "Emergency contact",
"emergencyphone": "Emergency phone",
"employmentdate": "Employment date",
"position": "Position",
"employmenttype": "Employment type",
"hoursperweek": "Hours/week",
"bookings": "bookings this year",
"revenue": "revenue this year",
"rating": "rating",
"employedsince": "employed since",
"settings": {
"label": "Settings",
"showinbooking": {
"label": "Show in online booking",
"desc": "Customers can select this employee"
},
"smsreminders": {
"label": "Receive SMS reminders",
"desc": "Get notified about new bookings"
},
"editcalendar": {
"label": "Can edit own calendar",
"desc": "Allow changes to own bookings"
}
},
"notifications": {
"label": "Notifications",
"intro": "Choose which email notifications the employee should receive.",
"onlinebooking": "Receive email for online booking",
"manualbooking": "Receive email for manual booking",
"cancellation": "Receive email for cancellation",
"waitlist": "Receive email for waitlist signup",
"dailysummary": "Receive daily summary of tomorrow's bookings"
}
}
}
}

View file

@ -126,8 +126,8 @@ public class MockMenuService : IMenuService
{
Id = "employees",
Label = "Medarbejdere",
Icon = "ph-user",
Url = "/poc-medarbejdere.html",
Icon = "ph-users-three",
Url = "/medarbejdere",
MinimumRole = UserRole.Manager,
SortOrder = 4
}

View file

@ -24,9 +24,11 @@
<link rel="stylesheet" href="~/css/quick-stats.css">
<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/cash.css">
<link rel="stylesheet" href="~/css/auth.css">
<link rel="stylesheet" href="~/css/account.css">
<link rel="stylesheet" href="~/css/employees.css">
@await RenderSectionAsync("Styles", required: false)
</head>
<body class="has-demo-banner">

View file

@ -0,0 +1,439 @@
import { PurgeCSS } from 'purgecss';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Create reports directory if it doesn't exist
const reportsDir = './reports';
if (!fs.existsSync(reportsDir)) {
fs.mkdirSync(reportsDir);
}
console.log('🔍 Starting CSS Analysis for PlanTempus...\n');
// 1. Run PurgeCSS to find unused CSS
console.log('📊 Running PurgeCSS analysis...');
async function runPurgeCSS() {
const purgeCSSResults = await new PurgeCSS().purge({
content: [
'./Features/**/*.cshtml',
'./wwwroot/ts/**/*.ts'
],
css: [
'./wwwroot/css/*.css'
],
rejected: true,
rejectedCss: true,
safelist: {
standard: [
/^swp-/, // All custom web components
/^ph-/, // Phosphor icons
'active', // Tab states
'checked', // Checkbox states
'collapsed',
'expanded',
'hidden',
'has-demo-banner',
/^owner$/, // Role badges
/^admin$/,
/^leader$/,
/^employee$/,
/^purple$/, // Avatar colors
/^blue$/,
/^amber$/,
/^teal$/,
/^master$/, // Employee tags
/^senior$/,
/^junior$/,
/^cert$/,
/^draft$/, // Status badges
/^approved$/,
/^invited$/,
/^danger$/, // Button variants
/^primary$/,
/^secondary$/
]
}
});
// Calculate statistics
let totalOriginalSize = 0;
let totalPurgedSize = 0;
let totalRejected = 0;
const rejectedByFile = {};
purgeCSSResults.forEach(result => {
const fileName = path.basename(result.file);
const originalSize = result.css.length + (result.rejected ? result.rejected.join('').length : 0);
const purgedSize = result.css.length;
const rejectedSize = result.rejected ? result.rejected.length : 0;
totalOriginalSize += originalSize;
totalPurgedSize += purgedSize;
totalRejected += rejectedSize;
rejectedByFile[fileName] = {
originalSize,
purgedSize,
rejectedCount: rejectedSize,
rejected: result.rejected || []
};
});
const report = {
summary: {
totalFiles: purgeCSSResults.length,
totalOriginalSize,
totalPurgedSize,
totalRejected,
percentageRemoved: ((totalRejected / (totalOriginalSize || 1)) * 100).toFixed(2) + '%',
potentialSavings: totalOriginalSize - totalPurgedSize
},
fileDetails: rejectedByFile
};
fs.writeFileSync(
path.join(reportsDir, 'purgecss-report.json'),
JSON.stringify(report, null, 2)
);
console.log('✅ PurgeCSS analysis complete');
console.log(` - Total CSS rules analyzed: ${totalOriginalSize}`);
console.log(` - Unused CSS rules found: ${totalRejected}`);
console.log(` - Potential removal: ${report.summary.percentageRemoved}`);
return report;
}
// 2. Analyze CSS with basic stats
console.log('\n📊 Running CSS Stats analysis...');
function runCSSStats() {
const cssDir = './wwwroot/css';
const cssFiles = fs.readdirSync(cssDir)
.filter(file => file.endsWith('.css'))
.map(file => path.join(cssDir, file));
const stats = {};
cssFiles.forEach(file => {
if (fs.existsSync(file)) {
const fileName = path.basename(file);
const content = fs.readFileSync(file, 'utf8');
// Basic statistics
const lines = content.split('\n').length;
const size = Buffer.byteLength(content, 'utf8');
const rules = (content.match(/\{[^}]*\}/g) || []).length;
const selectors = (content.match(/[^{]+(?=\{)/g) || []).length;
const properties = (content.match(/[^:]+:[^;]+;/g) || []).length;
const colors = [...new Set(content.match(/#[0-9a-fA-F]{3,6}|rgba?\([^)]+\)|hsla?\([^)]+\)|var\(--color-[^)]+\)/g) || [])];
const mediaQueries = (content.match(/@media[^{]+/g) || []).length;
const cssVariables = [...new Set(content.match(/var\(--[^)]+\)/g) || [])];
stats[fileName] = {
lines,
size: `${(size / 1024).toFixed(2)} KB`,
sizeBytes: size,
rules,
selectors,
properties,
uniqueColors: colors.length,
colors: colors.slice(0, 10), // First 10 colors
mediaQueries,
cssVariables: cssVariables.length
};
}
});
fs.writeFileSync(
path.join(reportsDir, 'css-stats.json'),
JSON.stringify(stats, null, 2)
);
console.log('✅ CSS Stats analysis complete');
console.log(` - Files analyzed: ${Object.keys(stats).length}`);
return stats;
}
// 3. Generate HTML report
function generateHTMLReport(purgeReport, statsReport) {
const totalSize = Object.values(statsReport).reduce((sum, stat) => sum + stat.sizeBytes, 0);
const totalSizeKB = (totalSize / 1024).toFixed(2);
const totalLines = Object.values(statsReport).reduce((sum, stat) => sum + stat.lines, 0);
const html = `
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CSS Analysis Report - PlanTempus</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
h1 {
color: #14b8a6;
margin-bottom: 10px;
font-size: 2.5em;
}
.subtitle {
color: #666;
margin-bottom: 30px;
font-size: 1.1em;
}
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
.stat-card {
background: linear-gradient(135deg, #14b8a6 0%, #0d9488 100%);
color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.stat-card.warning {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
}
.stat-card.info {
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
}
.stat-card.success {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
}
.stat-value {
font-size: 2.5em;
font-weight: bold;
margin: 10px 0;
}
.stat-label {
font-size: 0.9em;
opacity: 0.9;
}
section {
margin-bottom: 40px;
}
h2 {
color: #333;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #14b8a6;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background: #f8f9fa;
font-weight: 600;
color: #555;
}
tr:hover {
background: #f8f9fa;
}
.file-detail {
background: #f8f9fa;
padding: 15px;
border-radius: 4px;
margin-bottom: 15px;
}
.rejected-list {
max-height: 200px;
overflow-y: auto;
background: white;
padding: 10px;
border-radius: 4px;
margin-top: 10px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
.badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.85em;
font-weight: 600;
}
.badge-danger { background: #ffebee; color: #c62828; }
.badge-warning { background: #fff3e0; color: #ef6c00; }
.badge-success { background: #e8f5e9; color: #2e7d32; }
.timestamp {
color: #999;
font-size: 0.9em;
margin-top: 30px;
text-align: center;
}
.color-palette {
display: flex;
gap: 5px;
flex-wrap: wrap;
margin-top: 10px;
}
.color-swatch {
width: 30px;
height: 30px;
border-radius: 4px;
border: 1px solid #ddd;
}
details {
margin-top: 10px;
}
summary {
cursor: pointer;
color: #14b8a6;
font-weight: 500;
}
</style>
</head>
<body>
<div class="container">
<h1>📊 CSS Analysis Report</h1>
<p class="subtitle">PlanTempus - Production CSS Analysis</p>
<div class="summary">
<div class="stat-card">
<div class="stat-label">Total CSS Size</div>
<div class="stat-value">${totalSizeKB} KB</div>
</div>
<div class="stat-card info">
<div class="stat-label">CSS Files</div>
<div class="stat-value">${purgeReport.summary.totalFiles}</div>
</div>
<div class="stat-card info">
<div class="stat-label">Total Lines</div>
<div class="stat-value">${totalLines.toLocaleString()}</div>
</div>
<div class="stat-card warning">
<div class="stat-label">Unused CSS Rules</div>
<div class="stat-value">${purgeReport.summary.totalRejected}</div>
</div>
</div>
<section>
<h2>📈 CSS Statistics by File</h2>
<table>
<thead>
<tr>
<th>File</th>
<th>Size</th>
<th>Lines</th>
<th>Rules</th>
<th>Selectors</th>
<th>Properties</th>
<th>CSS Vars</th>
</tr>
</thead>
<tbody>
${Object.entries(statsReport)
.sort((a, b) => b[1].sizeBytes - a[1].sizeBytes)
.map(([file, stats]) => `
<tr>
<td><strong>${file}</strong></td>
<td>${stats.size}</td>
<td>${stats.lines}</td>
<td>${stats.rules}</td>
<td>${stats.selectors}</td>
<td>${stats.properties}</td>
<td>${stats.cssVariables}</td>
</tr>
`).join('')}
</tbody>
</table>
</section>
<section>
<h2>🗑 Unused CSS by File</h2>
${Object.entries(purgeReport.fileDetails)
.sort((a, b) => b[1].rejectedCount - a[1].rejectedCount)
.map(([file, details]) => `
<div class="file-detail">
<h3>${file}</h3>
<p>
<span class="badge ${details.rejectedCount > 50 ? 'badge-danger' : details.rejectedCount > 20 ? 'badge-warning' : 'badge-success'}">
${details.rejectedCount} unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: ${details.originalSize} | After purge: ${details.purgedSize}
</span>
</p>
${details.rejectedCount > 0 ? `
<details>
<summary>Show unused selectors</summary>
<div class="rejected-list">
${details.rejected.slice(0, 50).join('<br>')}
${details.rejected.length > 50 ? `<br><em>... and ${details.rejected.length - 50} more</em>` : ''}
</div>
</details>
` : '<p style="color: #2e7d32; margin-top: 10px;">✅ No unused CSS found!</p>'}
</div>
`).join('')}
</section>
<section>
<h2>💡 Recommendations</h2>
<ul style="line-height: 2;">
${purgeReport.summary.totalRejected > 100 ?
'<li>⚠️ <strong>High number of unused CSS rules detected.</strong> Consider removing unused styles to improve performance.</li>' :
'<li>✅ CSS usage is relatively clean.</li>'}
${Object.values(purgeReport.fileDetails).some(d => d.rejectedCount > 50) ?
'<li>⚠️ Some files have significant unused CSS. Review these files for optimization opportunities.</li>' : ''}
<li>📦 Consider consolidating similar styles to reduce duplication.</li>
<li>🎨 Review color usage - ensure all colors use CSS variables from design-tokens.css.</li>
<li>📋 Reference COMPONENT-CATALOG.md when adding new components to avoid duplication.</li>
</ul>
</section>
<p class="timestamp">Report generated: ${new Date().toLocaleString('da-DK')}</p>
</div>
</body>
</html>
`;
fs.writeFileSync(path.join(reportsDir, 'css-analysis-report.html'), html);
console.log('\n✅ HTML report generated: reports/css-analysis-report.html');
}
// Run all analyses
(async () => {
try {
const purgeReport = await runPurgeCSS();
const statsReport = runCSSStats();
generateHTMLReport(purgeReport, statsReport);
console.log('\n🎉 CSS Analysis Complete!');
console.log('📄 Reports generated in ./reports/ directory');
console.log(' - purgecss-report.json (detailed unused CSS data)');
console.log(' - css-stats.json (CSS statistics)');
console.log(' - css-analysis-report.html (visual report)');
console.log('\n💡 Open reports/css-analysis-report.html in your browser to view the full report');
} catch (error) {
console.error('❌ Error during analysis:', error);
process.exit(1);
}
})();

View file

@ -5,7 +5,8 @@
"packages": {
"": {
"devDependencies": {
"esbuild": "^0.27.2"
"esbuild": "^0.27.2",
"purgecss": "^6.0.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
@ -450,6 +451,150 @@
"node": ">=18"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
"strip-ansi": "^7.0.1",
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
"wrap-ansi": "^8.1.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=14"
}
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true,
"license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
},
"engines": {
"node": ">=4"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true,
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
@ -491,6 +636,500 @@
"@esbuild/win32-ia32": "0.27.2",
"@esbuild/win32-x64": "0.27.2"
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"optionalDependencies": {
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-selector-parser": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/purgecss": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/purgecss/-/purgecss-6.0.0.tgz",
"integrity": "sha512-s3EBxg5RSWmpqd0KGzNqPiaBbWDz1/As+2MzoYVGMqgDqRTLBhJW6sywfTBek7OwNfoS/6pS0xdtvChNhFj2cw==",
"dev": true,
"license": "MIT",
"dependencies": {
"commander": "^12.0.0",
"glob": "^10.3.10",
"postcss": "^8.4.4",
"postcss-selector-parser": "^6.0.7"
},
"bin": {
"purgecss": "bin/purgecss.js"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/string-width-cjs": {
"name": "string-width",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/string-width-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/strip-ansi-cjs": {
"name": "strip-ansi",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs": {
"name": "wrap-ansi",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
}
}
}

View file

@ -1,5 +1,10 @@
{
"type": "module",
"devDependencies": {
"esbuild": "^0.27.2"
"esbuild": "^0.27.2",
"purgecss": "^6.0.0"
},
"scripts": {
"analyze-css": "node analyze-css.js"
}
}

View file

@ -0,0 +1,776 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CSS Analysis Report - PlanTempus</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
h1 {
color: #14b8a6;
margin-bottom: 10px;
font-size: 2.5em;
}
.subtitle {
color: #666;
margin-bottom: 30px;
font-size: 1.1em;
}
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
.stat-card {
background: linear-gradient(135deg, #14b8a6 0%, #0d9488 100%);
color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.stat-card.warning {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
}
.stat-card.info {
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
}
.stat-card.success {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
}
.stat-value {
font-size: 2.5em;
font-weight: bold;
margin: 10px 0;
}
.stat-label {
font-size: 0.9em;
opacity: 0.9;
}
section {
margin-bottom: 40px;
}
h2 {
color: #333;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #14b8a6;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background: #f8f9fa;
font-weight: 600;
color: #555;
}
tr:hover {
background: #f8f9fa;
}
.file-detail {
background: #f8f9fa;
padding: 15px;
border-radius: 4px;
margin-bottom: 15px;
}
.rejected-list {
max-height: 200px;
overflow-y: auto;
background: white;
padding: 10px;
border-radius: 4px;
margin-top: 10px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
.badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.85em;
font-weight: 600;
}
.badge-danger { background: #ffebee; color: #c62828; }
.badge-warning { background: #fff3e0; color: #ef6c00; }
.badge-success { background: #e8f5e9; color: #2e7d32; }
.timestamp {
color: #999;
font-size: 0.9em;
margin-top: 30px;
text-align: center;
}
.color-palette {
display: flex;
gap: 5px;
flex-wrap: wrap;
margin-top: 10px;
}
.color-swatch {
width: 30px;
height: 30px;
border-radius: 4px;
border: 1px solid #ddd;
}
details {
margin-top: 10px;
}
summary {
cursor: pointer;
color: #14b8a6;
font-weight: 500;
}
</style>
</head>
<body>
<div class="container">
<h1>📊 CSS Analysis Report</h1>
<p class="subtitle">PlanTempus - Production CSS Analysis</p>
<div class="summary">
<div class="stat-card">
<div class="stat-label">Total CSS Size</div>
<div class="stat-value">132.81 KB</div>
</div>
<div class="stat-card info">
<div class="stat-label">CSS Files</div>
<div class="stat-value">21</div>
</div>
<div class="stat-card info">
<div class="stat-label">Total Lines</div>
<div class="stat-value">6.033</div>
</div>
<div class="stat-card warning">
<div class="stat-label">Unused CSS Rules</div>
<div class="stat-value">61</div>
</div>
</div>
<section>
<h2>📈 CSS Statistics by File</h2>
<table>
<thead>
<tr>
<th>File</th>
<th>Size</th>
<th>Lines</th>
<th>Rules</th>
<th>Selectors</th>
<th>Properties</th>
<th>CSS Vars</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>auth.css</strong></td>
<td>23.66 KB</td>
<td>1144</td>
<td>169</td>
<td>173</td>
<td>571</td>
<td>46</td>
</tr>
<tr>
<td><strong>cash.css</strong></td>
<td>19.38 KB</td>
<td>898</td>
<td>132</td>
<td>135</td>
<td>415</td>
<td>42</td>
</tr>
<tr>
<td><strong>employees.css</strong></td>
<td>15.55 KB</td>
<td>722</td>
<td>105</td>
<td>108</td>
<td>345</td>
<td>37</td>
</tr>
<tr>
<td><strong>design-tokens.css</strong></td>
<td>9.02 KB</td>
<td>318</td>
<td>35</td>
<td>36</td>
<td>192</td>
<td>25</td>
</tr>
<tr>
<td><strong>account.css</strong></td>
<td>8.83 KB</td>
<td>402</td>
<td>60</td>
<td>63</td>
<td>173</td>
<td>31</td>
</tr>
<tr>
<td><strong>drawers.css</strong></td>
<td>6.36 KB</td>
<td>297</td>
<td>38</td>
<td>38</td>
<td>159</td>
<td>32</td>
</tr>
<tr>
<td><strong>page.css</strong></td>
<td>6.00 KB</td>
<td>276</td>
<td>38</td>
<td>40</td>
<td>117</td>
<td>35</td>
</tr>
<tr>
<td><strong>sidebar.css</strong></td>
<td>5.72 KB</td>
<td>247</td>
<td>30</td>
<td>30</td>
<td>119</td>
<td>24</td>
</tr>
<tr>
<td><strong>waitlist.css</strong></td>
<td>5.55 KB</td>
<td>251</td>
<td>30</td>
<td>30</td>
<td>131</td>
<td>31</td>
</tr>
<tr>
<td><strong>stats.css</strong></td>
<td>5.18 KB</td>
<td>232</td>
<td>30</td>
<td>32</td>
<td>78</td>
<td>27</td>
</tr>
<tr>
<td><strong>bookings.css</strong></td>
<td>4.27 KB</td>
<td>176</td>
<td>28</td>
<td>28</td>
<td>75</td>
<td>27</td>
</tr>
<tr>
<td><strong>topbar.css</strong></td>
<td>3.79 KB</td>
<td>181</td>
<td>19</td>
<td>19</td>
<td>103</td>
<td>20</td>
</tr>
<tr>
<td><strong>controls.css</strong></td>
<td>3.32 KB</td>
<td>149</td>
<td>19</td>
<td>19</td>
<td>79</td>
<td>20</td>
</tr>
<tr>
<td><strong>demo-banner.css</strong></td>
<td>2.94 KB</td>
<td>146</td>
<td>19</td>
<td>21</td>
<td>66</td>
<td>9</td>
</tr>
<tr>
<td><strong>attentions.css</strong></td>
<td>2.92 KB</td>
<td>115</td>
<td>15</td>
<td>15</td>
<td>45</td>
<td>15</td>
</tr>
<tr>
<td><strong>design-system.css</strong></td>
<td>2.30 KB</td>
<td>105</td>
<td>20</td>
<td>20</td>
<td>37</td>
<td>20</td>
</tr>
<tr>
<td><strong>tabs.css</strong></td>
<td>2.13 KB</td>
<td>95</td>
<td>11</td>
<td>11</td>
<td>42</td>
<td>19</td>
</tr>
<tr>
<td><strong>base.css</strong></td>
<td>2.06 KB</td>
<td>119</td>
<td>15</td>
<td>15</td>
<td>47</td>
<td>8</td>
</tr>
<tr>
<td><strong>notifications.css</strong></td>
<td>1.67 KB</td>
<td>70</td>
<td>8</td>
<td>8</td>
<td>27</td>
<td>8</td>
</tr>
<tr>
<td><strong>app-layout.css</strong></td>
<td>1.28 KB</td>
<td>51</td>
<td>5</td>
<td>5</td>
<td>18</td>
<td>6</td>
</tr>
<tr>
<td><strong>quick-stats.css</strong></td>
<td>0.88 KB</td>
<td>39</td>
<td>4</td>
<td>4</td>
<td>15</td>
<td>11</td>
</tr>
</tbody>
</table>
</section>
<section>
<h2>🗑️ Unused CSS by File</h2>
<div class="file-detail">
<h3>design-tokens.css</h3>
<p>
<span class="badge badge-warning">
24 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 7917 | After purge: 7639
</span>
</p>
<details>
<summary>Show unused selectors</summary>
<div class="rejected-list">
.is-red<br>.is-pink<br>.is-magenta<br>.is-purple<br>.is-violet<br>.is-deep-purple<br>.is-indigo<br>.is-blue<br>.is-light-blue<br>.is-cyan<br>.is-teal<br>.is-green<br>.is-light-green<br>.is-lime<br>.is-yellow<br>.is-amber<br>.is-orange<br>.is-deep-orange<br>.status-confirmed<br>.status-pending<br>.status-inprogress<br>.status-error<br>.status-active<br>.status-inactive
</div>
</details>
</div>
<div class="file-detail">
<h3>design-system.css</h3>
<p>
<span class="badge badge-success">
11 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 2095 | After purge: 2056
</span>
</p>
<details>
<summary>Show unused selectors</summary>
<div class="rejected-list">
h2<br> h3<br> h4<br> h5<br> h6<br>h2<br>h3<br>h4<br>h5<br>h6<br>:focus-visible
</div>
</details>
</div>
<div class="file-detail">
<h3>bookings.css</h3>
<p>
<span class="badge badge-success">
6 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 3924 | After purge: 3756
</span>
</p>
<details>
<summary>Show unused selectors</summary>
<div class="rejected-list">
swp-booking-item.inprogress<br>swp-booking-indicator.green<br>swp-booking-status.confirmed<br>swp-booking-status.pending<br>swp-booking-status.inprogress<br>
swp-booking-status.in-progress
</div>
</details>
</div>
<div class="file-detail">
<h3>attentions.css</h3>
<p>
<span class="badge badge-success">
5 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 2524 | After purge: 2359
</span>
</p>
<details>
<summary>Show unused selectors</summary>
<div class="rejected-list">
swp-attention-item.urgent<br>swp-attention-item.urgent:hover<br>swp-attention-item.info<br>swp-attention-item.urgent swp-attention-icon<br>swp-attention-item.info swp-attention-icon
</div>
</details>
</div>
<div class="file-detail">
<h3>base.css</h3>
<p>
<span class="badge badge-success">
4 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 1952 | After purge: 1930
</span>
</p>
<details>
<summary>Show unused selectors</summary>
<div class="rejected-list">
ul<br> ol<br>img<br>:focus-visible
</div>
</details>
</div>
<div class="file-detail">
<h3>auth.css</h3>
<p>
<span class="badge badge-success">
4 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 23887 | After purge: 23816
</span>
</p>
<details>
<summary>Show unused selectors</summary>
<div class="rejected-list">
swp-btn.social<br>swp-btn.social:hover<br>swp-btn.social img<br>swp-plan-badge.free
</div>
</details>
</div>
<div class="file-detail">
<h3>stats.css</h3>
<p>
<span class="badge badge-success">
3 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 5298 | After purge: 5229
</span>
</p>
<details>
<summary>Show unused selectors</summary>
<div class="rejected-list">
swp-stat-trend.up<br>swp-stat-trend.down<br>
swp-stat-card.red swp-stat-value
</div>
</details>
</div>
<div class="file-detail">
<h3>account.css</h3>
<p>
<span class="badge badge-success">
2 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 8829 | After purge: 8777
</span>
</p>
<details>
<summary>Show unused selectors</summary>
<div class="rejected-list">
swp-invoice-status.pending<br>swp-invoice-status.overdue
</div>
</details>
</div>
<div class="file-detail">
<h3>drawers.css</h3>
<p>
<span class="badge badge-success">
1 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 6485 | After purge: 6467
</span>
</p>
<details>
<summary>Show unused selectors</summary>
<div class="rejected-list">
[data-drawer="xl"]
</div>
</details>
</div>
<div class="file-detail">
<h3>cash.css</h3>
<p>
<span class="badge badge-success">
1 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 19816 | After purge: 19778
</span>
</p>
<details>
<summary>Show unused selectors</summary>
<div class="rejected-list">
swp-cash-stat.user swp-cash-stat-value
</div>
</details>
</div>
<div class="file-detail">
<h3>waitlist.css</h3>
<p>
<span class="badge badge-success">
0 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 5686 | After purge: 5686
</span>
</p>
<p style="color: #2e7d32; margin-top: 10px;">✅ No unused CSS found!</p>
</div>
<div class="file-detail">
<h3>topbar.css</h3>
<p>
<span class="badge badge-success">
0 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 3885 | After purge: 3885
</span>
</p>
<p style="color: #2e7d32; margin-top: 10px;">✅ No unused CSS found!</p>
</div>
<div class="file-detail">
<h3>tabs.css</h3>
<p>
<span class="badge badge-success">
0 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 2183 | After purge: 2183
</span>
</p>
<p style="color: #2e7d32; margin-top: 10px;">✅ No unused CSS found!</p>
</div>
<div class="file-detail">
<h3>sidebar.css</h3>
<p>
<span class="badge badge-success">
0 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 5859 | After purge: 5859
</span>
</p>
<p style="color: #2e7d32; margin-top: 10px;">✅ No unused CSS found!</p>
</div>
<div class="file-detail">
<h3>quick-stats.css</h3>
<p>
<span class="badge badge-success">
0 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 899 | After purge: 899
</span>
</p>
<p style="color: #2e7d32; margin-top: 10px;">✅ No unused CSS found!</p>
</div>
<div class="file-detail">
<h3>page.css</h3>
<p>
<span class="badge badge-success">
0 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 6140 | After purge: 6140
</span>
</p>
<p style="color: #2e7d32; margin-top: 10px;">✅ No unused CSS found!</p>
</div>
<div class="file-detail">
<h3>notifications.css</h3>
<p>
<span class="badge badge-success">
0 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 1712 | After purge: 1712
</span>
</p>
<p style="color: #2e7d32; margin-top: 10px;">✅ No unused CSS found!</p>
</div>
<div class="file-detail">
<h3>employees.css</h3>
<p>
<span class="badge badge-success">
0 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 15923 | After purge: 15923
</span>
</p>
<p style="color: #2e7d32; margin-top: 10px;">✅ No unused CSS found!</p>
</div>
<div class="file-detail">
<h3>demo-banner.css</h3>
<p>
<span class="badge badge-success">
0 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 3014 | After purge: 3014
</span>
</p>
<p style="color: #2e7d32; margin-top: 10px;">✅ No unused CSS found!</p>
</div>
<div class="file-detail">
<h3>controls.css</h3>
<p>
<span class="badge badge-success">
0 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 3397 | After purge: 3397
</span>
</p>
<p style="color: #2e7d32; margin-top: 10px;">✅ No unused CSS found!</p>
</div>
<div class="file-detail">
<h3>app-layout.css</h3>
<p>
<span class="badge badge-success">
0 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 1306 | After purge: 1306
</span>
</p>
<p style="color: #2e7d32; margin-top: 10px;">✅ No unused CSS found!</p>
</div>
</section>
<section>
<h2>💡 Recommendations</h2>
<ul style="line-height: 2;">
<li>✅ CSS usage is relatively clean.</li>
<li>📦 Consider consolidating similar styles to reduce duplication.</li>
<li>🎨 Review color usage - ensure all colors use CSS variables from design-tokens.css.</li>
<li>📋 Reference COMPONENT-CATALOG.md when adding new components to avoid duplication.</li>
</ul>
</section>
<p class="timestamp">Report generated: 12.1.2026, 21.57.11</p>
</div>
</body>
</html>

View file

@ -0,0 +1,437 @@
{
"account.css": {
"lines": 402,
"size": "8.83 KB",
"sizeBytes": 9037,
"rules": 60,
"selectors": 63,
"properties": 173,
"uniqueColors": 13,
"colors": [
"var(--color-text)",
"var(--color-teal)",
"var(--color-background-alt)",
"var(--color-text-secondary)",
"var(--color-surface)",
"var(--color-purple)",
"var(--color-amber)",
"rgba(0, 0, 0, 0.08)",
"var(--color-border)",
"var(--color-blue)"
],
"mediaQueries": 3,
"cssVariables": 31
},
"app-layout.css": {
"lines": 51,
"size": "1.28 KB",
"sizeBytes": 1306,
"rules": 5,
"selectors": 5,
"properties": 18,
"uniqueColors": 2,
"colors": [
"var(--color-background)",
"rgba(0, 0, 0, 0.5)"
],
"mediaQueries": 0,
"cssVariables": 6
},
"attentions.css": {
"lines": 115,
"size": "2.92 KB",
"sizeBytes": 2992,
"rules": 15,
"selectors": 15,
"properties": 45,
"uniqueColors": 8,
"colors": [
"var(--color-background-alt)",
"var(--color-border)",
"var(--color-background-hover)",
"var(--color-red)",
"var(--color-amber)",
"var(--color-blue)",
"var(--color-text-secondary)",
"var(--color-teal)"
],
"mediaQueries": 0,
"cssVariables": 15
},
"auth.css": {
"lines": 1144,
"size": "23.66 KB",
"sizeBytes": 24232,
"rules": 169,
"selectors": 173,
"properties": 571,
"uniqueColors": 17,
"colors": [
"var(--color-teal)",
"#00695c",
"rgba(255,255,255,0.1)",
"rgba(255,255,255,0.08)",
"var(--color-background)",
"var(--color-text)",
"var(--color-text-secondary)",
"var(--color-surface)",
"var(--color-border)",
"var(--color-purple)"
],
"mediaQueries": 3,
"cssVariables": 46
},
"base.css": {
"lines": 119,
"size": "2.06 KB",
"sizeBytes": 2105,
"rules": 15,
"selectors": 15,
"properties": 47,
"uniqueColors": 4,
"colors": [
"var(--color-text)",
"var(--color-background)",
"var(--color-teal)",
"var(--color-teal-light)"
],
"mediaQueries": 0,
"cssVariables": 8
},
"bookings.css": {
"lines": 176,
"size": "4.27 KB",
"sizeBytes": 4369,
"rules": 28,
"selectors": 28,
"properties": 75,
"uniqueColors": 10,
"colors": [
"var(--color-background-alt)",
"var(--color-background-hover)",
"var(--color-border)",
"var(--color-teal)",
"var(--color-text)",
"var(--color-text-secondary)",
"var(--color-blue)",
"var(--color-purple)",
"var(--color-amber)",
"var(--color-green)"
],
"mediaQueries": 0,
"cssVariables": 27
},
"cash.css": {
"lines": 898,
"size": "19.38 KB",
"sizeBytes": 19850,
"rules": 132,
"selectors": 135,
"properties": 415,
"uniqueColors": 14,
"colors": [
"var(--color-surface)",
"var(--color-border)",
"var(--color-text-secondary)",
"var(--color-teal)",
"var(--color-background-alt)",
"var(--color-text)",
"var(--color-amber)",
"var(--color-red)",
"var(--color-blue)",
"var(--color-background-hover)"
],
"mediaQueries": 3,
"cssVariables": 42
},
"controls.css": {
"lines": 149,
"size": "3.32 KB",
"sizeBytes": 3397,
"rules": 19,
"selectors": 19,
"properties": 79,
"uniqueColors": 8,
"colors": [
"var(--color-border)",
"var(--color-text)",
"var(--color-text-secondary)",
"var(--color-background)",
"var(--color-green)",
"var(--color-red)",
"var(--color-background-alt)",
"var(--color-teal)"
],
"mediaQueries": 0,
"cssVariables": 20
},
"demo-banner.css": {
"lines": 146,
"size": "2.94 KB",
"sizeBytes": 3014,
"rules": 19,
"selectors": 21,
"properties": 66,
"uniqueColors": 7,
"colors": [
"var(--color-teal)",
"#00796b",
"rgba(255, 255, 255, 0.95)",
"#00695c",
"rgba(0, 0, 0, 0.15)",
"rgba(255, 255, 255, 0.15)",
"rgba(255, 255, 255, 0.25)"
],
"mediaQueries": 2,
"cssVariables": 9
},
"design-system.css": {
"lines": 105,
"size": "2.30 KB",
"sizeBytes": 2356,
"rules": 20,
"selectors": 20,
"properties": 37,
"uniqueColors": 7,
"colors": [
"var(--color-text)",
"var(--color-background)",
"var(--color-text-secondary)",
"var(--color-teal)",
"var(--color-primary)",
"var(--color-border)",
"var(--color-text-muted)"
],
"mediaQueries": 0,
"cssVariables": 20
},
"design-tokens.css": {
"lines": 318,
"size": "9.02 KB",
"sizeBytes": 9235,
"rules": 35,
"selectors": 36,
"properties": 192,
"uniqueColors": 54,
"colors": [
"#fff",
"#f5f5f5",
"#f0f0f0",
"#fafafa",
"#e0e0e0",
"#333",
"#666",
"#999",
"#1976d2",
"#00897b"
],
"mediaQueries": 2,
"cssVariables": 25
},
"drawers.css": {
"lines": 297,
"size": "6.36 KB",
"sizeBytes": 6513,
"rules": 38,
"selectors": 38,
"properties": 159,
"uniqueColors": 9,
"colors": [
"var(--color-surface)",
"var(--color-border)",
"var(--color-text)",
"var(--color-text-secondary)",
"var(--color-background-alt)",
"var(--color-background-hover)",
"var(--color-teal)",
"var(--color-background)",
"var(--color-red)"
],
"mediaQueries": 0,
"cssVariables": 32
},
"employees.css": {
"lines": 722,
"size": "15.55 KB",
"sizeBytes": 15923,
"rules": 105,
"selectors": 108,
"properties": 345,
"uniqueColors": 15,
"colors": [
"var(--color-text-secondary)",
"var(--color-text)",
"var(--color-border)",
"var(--color-teal)",
"var(--color-surface)",
"rgba(0, 0, 0, 0.08)",
"var(--color-background-alt)",
"var(--color-background-hover)",
"var(--color-purple)",
"var(--color-blue)"
],
"mediaQueries": 3,
"cssVariables": 37
},
"notifications.css": {
"lines": 70,
"size": "1.67 KB",
"sizeBytes": 1712,
"rules": 8,
"selectors": 8,
"properties": 27,
"uniqueColors": 4,
"colors": [
"var(--color-background-alt)",
"var(--color-background-hover)",
"var(--color-teal)",
"var(--color-text-secondary)"
],
"mediaQueries": 0,
"cssVariables": 8
},
"page.css": {
"lines": 276,
"size": "6.00 KB",
"sizeBytes": 6141,
"rules": 38,
"selectors": 40,
"properties": 117,
"uniqueColors": 8,
"colors": [
"var(--color-surface)",
"var(--color-border)",
"var(--color-text)",
"var(--color-text-secondary)",
"var(--color-teal)",
"var(--color-purple)",
"var(--color-background)",
"var(--color-background-hover)"
],
"mediaQueries": 2,
"cssVariables": 35
},
"quick-stats.css": {
"lines": 39,
"size": "0.88 KB",
"sizeBytes": 899,
"rules": 4,
"selectors": 4,
"properties": 15,
"uniqueColors": 3,
"colors": [
"var(--color-background-alt)",
"var(--color-text)",
"var(--color-text-secondary)"
],
"mediaQueries": 0,
"cssVariables": 11
},
"sidebar.css": {
"lines": 247,
"size": "5.72 KB",
"sizeBytes": 5859,
"rules": 30,
"selectors": 30,
"properties": 119,
"uniqueColors": 9,
"colors": [
"var(--color-surface)",
"var(--color-border)",
"var(--color-teal)",
"var(--color-text)",
"var(--color-text-secondary)",
"var(--color-background-hover)",
"var(--color-teal-light)",
"var(--color-amber)",
"var(--color-red)"
],
"mediaQueries": 0,
"cssVariables": 24
},
"stats.css": {
"lines": 232,
"size": "5.18 KB",
"sizeBytes": 5301,
"rules": 30,
"selectors": 32,
"properties": 78,
"uniqueColors": 14,
"colors": [
"var(--color-surface)",
"var(--color-border)",
"var(--color-text)",
"var(--color-text-secondary)",
"var(--color-text-muted)",
"var(--color-green)",
"var(--color-red)",
"var(--color-teal)",
"var(--color-amber)",
"var(--color-purple)"
],
"mediaQueries": 2,
"cssVariables": 27
},
"tabs.css": {
"lines": 95,
"size": "2.13 KB",
"sizeBytes": 2183,
"rules": 11,
"selectors": 11,
"properties": 42,
"uniqueColors": 6,
"colors": [
"var(--color-surface)",
"var(--color-border)",
"var(--color-text-secondary)",
"var(--color-text)",
"var(--color-background-alt)",
"var(--color-teal)"
],
"mediaQueries": 0,
"cssVariables": 19
},
"topbar.css": {
"lines": 181,
"size": "3.79 KB",
"sizeBytes": 3885,
"rules": 19,
"selectors": 19,
"properties": 103,
"uniqueColors": 8,
"colors": [
"var(--color-surface)",
"var(--color-border)",
"var(--color-background)",
"var(--color-teal)",
"var(--color-text-secondary)",
"var(--color-text)",
"var(--color-background-hover)",
"var(--color-red)"
],
"mediaQueries": 0,
"cssVariables": 20
},
"waitlist.css": {
"lines": 251,
"size": "5.55 KB",
"sizeBytes": 5686,
"rules": 30,
"selectors": 30,
"properties": 131,
"uniqueColors": 9,
"colors": [
"var(--color-surface)",
"var(--color-border)",
"var(--color-teal)",
"var(--color-text-secondary)",
"var(--color-text)",
"var(--color-background-alt)",
"var(--color-background)",
"var(--color-amber)",
"var(--color-background-hover)"
],
"mediaQueries": 0,
"cssVariables": 31
}
}

View file

@ -0,0 +1,209 @@
{
"summary": {
"totalFiles": 21,
"totalOriginalSize": 132731,
"totalPurgedSize": 131811,
"totalRejected": 61,
"percentageRemoved": "0.05%",
"potentialSavings": 920
},
"fileDetails": {
"waitlist.css": {
"originalSize": 5686,
"purgedSize": 5686,
"rejectedCount": 0,
"rejected": []
},
"topbar.css": {
"originalSize": 3885,
"purgedSize": 3885,
"rejectedCount": 0,
"rejected": []
},
"tabs.css": {
"originalSize": 2183,
"purgedSize": 2183,
"rejectedCount": 0,
"rejected": []
},
"stats.css": {
"originalSize": 5298,
"purgedSize": 5229,
"rejectedCount": 3,
"rejected": [
"swp-stat-trend.up",
"swp-stat-trend.down",
"\nswp-stat-card.red swp-stat-value"
]
},
"sidebar.css": {
"originalSize": 5859,
"purgedSize": 5859,
"rejectedCount": 0,
"rejected": []
},
"quick-stats.css": {
"originalSize": 899,
"purgedSize": 899,
"rejectedCount": 0,
"rejected": []
},
"page.css": {
"originalSize": 6140,
"purgedSize": 6140,
"rejectedCount": 0,
"rejected": []
},
"notifications.css": {
"originalSize": 1712,
"purgedSize": 1712,
"rejectedCount": 0,
"rejected": []
},
"employees.css": {
"originalSize": 15923,
"purgedSize": 15923,
"rejectedCount": 0,
"rejected": []
},
"drawers.css": {
"originalSize": 6485,
"purgedSize": 6467,
"rejectedCount": 1,
"rejected": [
"[data-drawer=\"xl\"]"
]
},
"design-tokens.css": {
"originalSize": 7917,
"purgedSize": 7639,
"rejectedCount": 24,
"rejected": [
".is-red",
".is-pink",
".is-magenta",
".is-purple",
".is-violet",
".is-deep-purple",
".is-indigo",
".is-blue",
".is-light-blue",
".is-cyan",
".is-teal",
".is-green",
".is-light-green",
".is-lime",
".is-yellow",
".is-amber",
".is-orange",
".is-deep-orange",
".status-confirmed",
".status-pending",
".status-inprogress",
".status-error",
".status-active",
".status-inactive"
]
},
"design-system.css": {
"originalSize": 2095,
"purgedSize": 2056,
"rejectedCount": 11,
"rejected": [
" h2",
" h3",
" h4",
" h5",
" h6",
"h2",
"h3",
"h4",
"h5",
"h6",
":focus-visible"
]
},
"demo-banner.css": {
"originalSize": 3014,
"purgedSize": 3014,
"rejectedCount": 0,
"rejected": []
},
"controls.css": {
"originalSize": 3397,
"purgedSize": 3397,
"rejectedCount": 0,
"rejected": []
},
"cash.css": {
"originalSize": 19816,
"purgedSize": 19778,
"rejectedCount": 1,
"rejected": [
"swp-cash-stat.user swp-cash-stat-value"
]
},
"bookings.css": {
"originalSize": 3924,
"purgedSize": 3756,
"rejectedCount": 6,
"rejected": [
"swp-booking-item.inprogress",
"swp-booking-indicator.green",
"swp-booking-status.confirmed",
"swp-booking-status.pending",
"swp-booking-status.inprogress",
"\nswp-booking-status.in-progress"
]
},
"base.css": {
"originalSize": 1952,
"purgedSize": 1930,
"rejectedCount": 4,
"rejected": [
"ul",
" ol",
"img",
":focus-visible"
]
},
"auth.css": {
"originalSize": 23887,
"purgedSize": 23816,
"rejectedCount": 4,
"rejected": [
"swp-btn.social",
"swp-btn.social:hover",
"swp-btn.social img",
"swp-plan-badge.free"
]
},
"attentions.css": {
"originalSize": 2524,
"purgedSize": 2359,
"rejectedCount": 5,
"rejected": [
"swp-attention-item.urgent",
"swp-attention-item.urgent:hover",
"swp-attention-item.info",
"swp-attention-item.urgent swp-attention-icon",
"swp-attention-item.info swp-attention-icon"
]
},
"app-layout.css": {
"originalSize": 1306,
"purgedSize": 1306,
"rejectedCount": 0,
"rejected": []
},
"account.css": {
"originalSize": 8829,
"purgedSize": 8777,
"rejectedCount": 2,
"rejected": [
"swp-invoice-status.pending",
"swp-invoice-status.overdue"
]
}
}
}

View file

@ -0,0 +1,305 @@
# SWP Design System - Component Catalog
Reference for alle genbrugelige komponenter. **LAV ALDRIG EN NY KOMPONENT HVIS DEN ALLEREDE EKSISTERER HER.**
---
## Page Structure (page.css)
| Element | Beskrivelse | Eksempel |
|---------|-------------|----------|
| `swp-page-container` | Hovedcontainer for side | `<swp-page-container>...</swp-page-container>` |
| `swp-page-header` | Side header med titel og actions | Flex, space-between |
| `swp-page-title` | Titel-wrapper med h1 og p | h1 + subtitle |
| `swp-page-actions` | Action buttons i header | Flex gap |
| `swp-card` | Standard card wrapper | Border, padding, rounded |
| `swp-card-header` | Card header | Flex, title + action |
| `swp-card-title` | Card titel med ikon | `<swp-card-title><i>...</i> Text</swp-card-title>` |
| `swp-card-content` | Card indhold | Block |
---
## Stats Components (stats.css)
### Containers
| Element | Kolonner | Brug |
|---------|----------|------|
| `swp-stats-bar` | 4 kolonner | Dashboard stats |
| `swp-stats-grid` | 4 kolonner | Grid layout |
| `swp-stats-row` | 3 kolonner | Feature pages (Employees, etc.) |
### Stat Card
```html
<swp-stat-card class="[variant]">
<swp-stat-value>42</swp-stat-value>
<swp-stat-label>Aktive brugere</swp-stat-label>
</swp-stat-card>
```
**Varianter (class):**
- `highlight` / `teal` - Teal farve
- `success` - Grøn
- `warning` / `amber` - Amber/orange
- `danger` / `negative` / `red` - Rød
- `purple` - Lilla
- `highlight filled` - Filled teal baggrund
**VIGTIGT:** `swp-stat-value` bruger `font-family: var(--font-mono)` automatisk!
---
## Tabs (tabs.css)
```html
<swp-tab-bar>
<swp-tab class="active" data-tab="users">
<i class="ph ph-users"></i>
<span>Brugere</span>
</swp-tab>
<swp-tab data-tab="roles">
<i class="ph ph-shield-check"></i>
<span>Roller</span>
</swp-tab>
</swp-tab-bar>
<swp-tab-content data-tab="users" class="active">
<!-- Content -->
</swp-tab-content>
<swp-tab-content data-tab="roles">
<!-- Content -->
</swp-tab-content>
```
**VIGTIGT:**
- Aktiv tab: `class="active"` (IKKE data-active="true")
- Tab content: `class="active"` for at vise
---
## Buttons (cash.css)
```html
<swp-btn class="primary">
<i class="ph ph-plus"></i>
Tilføj
</swp-btn>
```
**Varianter:**
- `primary` - Teal baggrund, hvid tekst
- `secondary` - Hvid baggrund, border
- `ghost` - Transparent
---
## Badges (cash.css)
**ALLE badges bruger `swp-status-badge`** - kun farve og indhold ændres.
```html
<swp-status-badge class="[variant]">Tekst</swp-status-badge>
```
**Varianter:**
| Class | Farve | Brug |
|-------|-------|------|
| `approved` | Grøn | Godkendt status |
| `active` | Grøn | Aktiv status |
| `draft` | Amber | Kladde status |
| `invited` | Amber | Invitation sendt |
| `owner` | Teal | Ejer rolle |
| `admin` | Purple | Admin rolle |
| `leader` | Blue | Leder rolle |
| `employee` | Grå | Medarbejder rolle |
Automatisk dot via `::before` pseudo-element.
---
## Tables - Grid + Subgrid Pattern
### Struktur (ALTID følg dette mønster)
```html
<swp-[feature]-table>
<swp-[feature]-table-header>
<swp-[feature]-cell>Kolonne 1</swp-[feature]-cell>
<swp-[feature]-cell>Kolonne 2</swp-[feature]-cell>
</swp-[feature]-table-header>
<swp-[feature]-table-body>
<swp-[feature]-row>
<swp-[feature]-cell>Data 1</swp-[feature]-cell>
<swp-[feature]-cell>Data 2</swp-[feature]-cell>
</swp-[feature]-row>
</swp-[feature]-table-body>
</swp-[feature]-table>
```
### CSS Pattern
```css
swp-[feature]-table {
display: grid;
grid-template-columns: /* definer kolonner her */;
}
swp-[feature]-table-header,
swp-[feature]-table-body {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
}
swp-[feature]-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
}
```
### Eksisterende tabeller
| Feature | Container | Row | CSS fil |
|---------|-----------|-----|---------|
| Cash | `swp-cash-table` | `swp-cash-table-row` | cash.css |
| Employees | `swp-employee-table` | `swp-employee-row` | employees.css |
| Bookings | `swp-booking-list` | `swp-booking-item` | bookings.css |
| Notifications | `swp-notification-list` | `swp-notification-item` | notifications.css |
| Attentions | `swp-attention-list` | `swp-attention-item` | attentions.css |
---
## Table Cells - Standard Styling
```css
/* Header cells */
swp-[feature]-table-header swp-[feature]-cell {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-text-secondary);
}
/* Body cells */
swp-[feature]-cell {
padding: var(--spacing-5);
font-size: var(--font-size-base); /* ALTID base, ikke sm */
color: var(--color-text);
}
```
---
## Icon Buttons (employees.css)
```html
<swp-table-actions>
<swp-icon-btn title="Rediger">
<i class="ph ph-pencil"></i>
</swp-icon-btn>
<swp-icon-btn class="danger" title="Slet">
<i class="ph ph-trash"></i>
</swp-icon-btn>
</swp-table-actions>
```
---
## User Info Pattern (employees.css)
```html
<swp-user-info>
<swp-user-avatar class="[color]">MJ</swp-user-avatar>
<swp-user-details>
<swp-user-name>Maria Jensen</swp-user-name>
<swp-user-email>maria@example.com</swp-user-email>
</swp-user-details>
</swp-user-info>
```
**Avatar farver:** (ingen class = teal), `purple`, `blue`, `amber`
---
## Design Tokens (design-tokens.css)
### Farver
| Token | Brug |
|-------|------|
| `--color-teal` | Primary brand, success |
| `--color-green` | Success, positive |
| `--color-amber` | Warning, pending |
| `--color-red` | Error, danger |
| `--color-purple` | Special, AI |
| `--color-blue` | Info |
### Spacing
```css
--spacing-1: 2px;
--spacing-2: 4px;
--spacing-3: 6px;
--spacing-4: 8px;
--spacing-5: 10px;
--spacing-6: 12px;
--spacing-7: 14px;
--spacing-8: 16px;
--spacing-10: 20px;
--spacing-12: 24px;
```
### Font Sizes
```css
--font-size-xs: 11px;
--font-size-sm: 12px;
--font-size-md: 13px;
--font-size-base: 14px; /* Standard body text */
--font-size-lg: 16px;
--font-size-xl: 18px;
--font-size-2xl: 20px;
--font-size-3xl: 22px;
```
### Font Families
```css
--font-family: 'Poppins', sans-serif;
--font-mono: 'JetBrains Mono', monospace; /* Til tal/værdier */
```
---
## Checklist for Ny Side
1. [ ] Læs denne fil
2. [ ] List UI elementer der skal bruges
3. [ ] Match hver element med eksisterende komponent
4. [ ] Dokumenter kun NYE elementer der skal oprettes
5. [ ] Opret feature CSS med header der angiver genbrugte komponenter
6. [ ] Brug `var(--font-size-base)` for body text
7. [ ] Brug `var(--font-mono)` kun for tal/værdier
---
## Fil Reference
| Fil | Indhold |
|-----|---------|
| `design-tokens.css` | Farver, spacing, fonts, shadows |
| `design-system.css` | Base resets, typography |
| `page.css` | Page structure, cards |
| `stats.css` | Stat cards, stat rows |
| `tabs.css` | Tab bar, tab content |
| `cash.css` | Buttons, status badges, tables |
| `employees.css` | User info, role badges, employee table |
| `bookings.css` | Booking list items |
| `notifications.css` | Notification items |
| `attentions.css` | Attention items |

View file

@ -2,36 +2,9 @@
* Cash Register - Page Styling
*
* Filter bar, stats, table, forms, and difference box
* Reuses: swp-sticky-header, swp-header-content (page.css)
*/
/* ===========================================
STICKY HEADER CONTAINER
=========================================== */
swp-cash-sticky-header {
display: block;
position: sticky;
top: 0;
z-index: var(--z-sticky);
background: var(--color-surface);
overflow: visible;
}
/* Override tab-bar sticky when inside sticky header */
swp-cash-sticky-header swp-tab-bar {
position: static;
top: auto;
}
/* ===========================================
KASSE HEADER (Stats above tabs)
=========================================== */
swp-cash-header {
display: block;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: var(--spacing-10) var(--spacing-12);
}
/* ===========================================
FILTER BAR
=========================================== */
@ -375,16 +348,40 @@ swp-status-badge::before {
background: currentColor;
}
swp-status-badge.approved {
/* Status variants */
swp-status-badge.approved,
swp-status-badge.active {
background: color-mix(in srgb, var(--color-green) 15%, transparent);
color: var(--color-green);
}
swp-status-badge.draft {
swp-status-badge.draft,
swp-status-badge.invited {
background: color-mix(in srgb, var(--color-amber) 15%, transparent);
color: #b45309;
}
/* Role variants */
swp-status-badge.owner {
background: color-mix(in srgb, var(--color-teal) 15%, transparent);
color: var(--color-teal);
}
swp-status-badge.admin {
background: color-mix(in srgb, var(--color-purple) 15%, transparent);
color: var(--color-purple);
}
swp-status-badge.leader {
background: color-mix(in srgb, var(--color-blue) 15%, transparent);
color: var(--color-blue);
}
swp-status-badge.employee {
background: var(--color-background-alt);
color: var(--color-text-secondary);
}
/* ===========================================
TWO-COLUMN GRID (Detail View)
=========================================== */

View file

@ -0,0 +1,148 @@
/**
* Form Controls - Toggles, Checkboxes, Inputs
*
* Reusable form control components used across the application.
*/
/* ===========================================
TOGGLE SLIDER (Yes/No switch)
=========================================== */
swp-toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-3) 0;
border-bottom: 1px solid var(--color-border);
}
swp-toggle-row:last-child {
border-bottom: none;
}
swp-toggle-label {
font-size: var(--font-size-base);
color: var(--color-text);
}
swp-toggle-description {
display: block;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
margin-top: var(--spacing-1);
}
swp-toggle-slider {
display: inline-flex;
width: fit-content;
background: var(--color-background);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
overflow: hidden;
position: relative;
}
swp-toggle-slider::before {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: calc(50% - 4px);
height: calc(100% - 4px);
background: color-mix(in srgb, var(--color-green) 18%, white);
border-radius: var(--radius-sm);
transition: transform 200ms ease, background 200ms ease;
}
swp-toggle-slider[data-value="no"]::before {
transform: translateX(100%);
background: color-mix(in srgb, var(--color-red) 18%, white);
}
swp-toggle-option {
position: relative;
z-index: 1;
padding: var(--spacing-2) var(--spacing-5);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
cursor: pointer;
transition: color var(--transition-fast);
user-select: none;
}
swp-toggle-slider[data-value="yes"] swp-toggle-option:first-child {
color: var(--color-green);
font-weight: var(--font-weight-semibold);
}
swp-toggle-slider[data-value="no"] swp-toggle-option:last-child {
color: var(--color-red);
font-weight: var(--font-weight-semibold);
}
/* ===========================================
CHECKBOX LIST
=========================================== */
swp-checkbox-list {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
swp-checkbox-row {
display: flex;
align-items: flex-start;
gap: var(--spacing-4);
padding: var(--spacing-3) var(--spacing-4);
border-radius: var(--radius-md);
cursor: pointer;
transition: background var(--transition-fast);
}
swp-checkbox-row:hover {
background: var(--color-background-alt);
}
swp-checkbox-box {
width: 18px;
height: 18px;
border: 2px solid var(--color-border);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 1px;
transition: all var(--transition-fast);
}
swp-checkbox-row.checked swp-checkbox-box {
background: var(--color-teal);
border-color: var(--color-teal);
}
swp-checkbox-box svg {
width: 12px;
height: 12px;
fill: white;
opacity: 0;
transition: opacity var(--transition-fast);
}
swp-checkbox-row.checked swp-checkbox-box svg {
opacity: 1;
}
swp-checkbox-text {
font-size: var(--font-size-base);
color: var(--color-text);
line-height: 1.4;
}
/* Intro text for checkbox lists (e.g. notifications) */
swp-notification-intro {
display: block;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
margin-bottom: var(--spacing-5);
}

View file

@ -0,0 +1,721 @@
/**
* Employees Styles - User & Role Management
*
* Employees-specific styling only.
* Reuses: swp-stat-card (stats.css), swp-stats-row (stats.css), swp-tab-bar (tabs.css),
* swp-btn (cash.css), swp-status-badge (cash.css), swp-row-toggle (cash.css),
* swp-sticky-header, swp-header-content (page.css),
* swp-toggle-slider, swp-checkbox-list (controls.css)
*
* Creates: swp-employee-table, swp-employee-row, swp-user-info,
* swp-employee-avatar-large, swp-employee-detail-header,
* swp-fact-inline, swp-edit-row, swp-detail-grid
*/
/* ===========================================
USERS HEADER
=========================================== */
swp-users-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--section-gap);
}
swp-users-count {
display: flex;
align-items: center;
gap: var(--spacing-4);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
swp-users-count strong {
color: var(--color-text);
font-weight: var(--font-weight-semibold);
}
swp-users-progress {
width: 120px;
height: 6px;
background: var(--color-border);
border-radius: var(--radius-sm);
overflow: hidden;
}
swp-users-progress-bar {
display: block;
height: 100%;
background: var(--color-teal);
border-radius: var(--radius-sm);
transition: width var(--transition-normal);
}
/* ===========================================
EMPLOYEE TABLE (Grid + Subgrid)
=========================================== */
swp-employee-table-card {
display: block;
background: var(--color-surface);
border-radius: var(--radius-lg);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
swp-employee-table {
display: grid;
grid-template-columns: minmax(220px, 1fr) 120px 140px 120px 40px;
}
swp-employee-table-header {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
background: var(--color-background-alt);
padding: 0 var(--spacing-10);
}
swp-employee-table-body {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
}
swp-employee-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
padding: 0 var(--spacing-10);
border-bottom: 1px solid var(--color-border);
transition: background var(--transition-fast);
}
swp-employee-table-body swp-employee-row {
cursor: pointer;
}
swp-employee-table-body swp-employee-row:hover {
background: var(--color-background-hover);
}
swp-employee-row:last-child {
border-bottom: none;
}
swp-employee-cell {
padding: var(--spacing-5) 0;
font-size: var(--font-size-base);
color: var(--color-text);
}
/* Chevron cell (last column) */
swp-employee-cell:last-child {
display: flex;
align-items: center;
justify-content: center;
}
/* Header cells */
swp-employee-table-header swp-employee-cell {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-text-secondary);
padding-top: var(--spacing-5);
padding-bottom: var(--spacing-5);
}
/* ===========================================
USER INFO (Avatar + Details)
=========================================== */
swp-user-info {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
swp-user-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--color-teal);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
flex-shrink: 0;
}
swp-user-avatar.purple {
background: var(--color-purple);
}
swp-user-avatar.blue {
background: var(--color-blue);
}
swp-user-avatar.amber {
background: var(--color-amber);
}
swp-user-details {
min-width: 0;
}
swp-user-name {
display: block;
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
swp-user-email {
display: block;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ===========================================
TABLE ACTIONS
=========================================== */
swp-table-actions {
display: flex;
align-items: center;
gap: var(--spacing-2);
justify-content: flex-end;
}
swp-icon-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
background: transparent;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
swp-icon-btn:hover {
background: var(--color-background-alt);
color: var(--color-text);
}
swp-icon-btn.danger:hover {
background: color-mix(in srgb, var(--color-red) 10%, transparent);
color: var(--color-red);
}
swp-icon-btn i {
font-size: 18px;
}
/* ===========================================
PERMISSIONS MATRIX
=========================================== */
swp-permissions-matrix {
display: block;
background: var(--color-surface);
border-radius: var(--radius-lg);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
swp-permissions-matrix table {
width: 100%;
border-collapse: collapse;
}
swp-permissions-matrix th,
swp-permissions-matrix td {
padding: var(--spacing-5) var(--spacing-6);
text-align: center;
border-bottom: 1px solid var(--color-border);
font-size: var(--font-size-base);
}
swp-permissions-matrix th:first-child,
swp-permissions-matrix td:first-child {
text-align: left;
font-weight: var(--font-weight-medium);
}
swp-permissions-matrix thead th {
background: var(--color-background-alt);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-text-secondary);
}
swp-permissions-matrix tbody tr:last-child td {
border-bottom: none;
}
swp-permissions-matrix .permission-name {
display: flex;
align-items: center;
gap: var(--spacing-3);
font-size: var(--font-size-base);
color: var(--color-text);
}
swp-permissions-matrix .permission-name i {
font-size: 18px;
color: var(--color-text-secondary);
}
swp-permissions-matrix .check {
color: var(--color-teal);
font-size: 20px;
}
swp-permissions-matrix .no-access {
color: var(--color-border);
font-size: 16px;
}
/* ===========================================
EMPLOYEE DETAIL VIEW (replaces page content)
=========================================== */
/* Large avatar for detail view */
swp-employee-avatar-large {
width: 80px;
height: 80px;
border-radius: 50%;
background: var(--color-teal);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-semibold);
flex-shrink: 0;
}
swp-employee-avatar-large.purple { background: var(--color-purple); }
swp-employee-avatar-large.blue { background: var(--color-blue); }
swp-employee-avatar-large.amber { background: var(--color-amber); }
/* Detail header content (inside swp-header-content) */
swp-employee-detail-header {
display: flex;
gap: var(--spacing-12);
}
swp-employee-info {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--spacing-2);
min-width: 0;
}
/* Name row with name + tags + status */
swp-employee-name-row {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
swp-employee-name {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text);
}
/* Tags row */
swp-tags-row {
display: flex;
align-items: center;
gap: var(--spacing-2);
flex-wrap: wrap;
}
swp-tag {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
padding: var(--spacing-1) var(--spacing-3);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.3px;
border-radius: var(--radius-sm);
background: var(--color-background);
color: var(--color-text-secondary);
}
swp-tag.master {
background: color-mix(in srgb, var(--color-purple) 15%, white);
color: var(--color-purple);
}
swp-tag.senior {
background: color-mix(in srgb, var(--color-blue) 15%, white);
color: var(--color-blue);
}
swp-tag.junior {
background: color-mix(in srgb, var(--color-amber) 15%, white);
color: #b45309;
}
swp-tag.cert {
background: color-mix(in srgb, var(--color-teal) 15%, white);
color: var(--color-teal);
}
/* Employee status indicator */
swp-employee-status {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-2) var(--spacing-4);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
margin-left: auto;
}
swp-employee-status[data-active="true"] {
background: color-mix(in srgb, var(--color-green) 15%, white);
color: var(--color-green);
border: 1px solid color-mix(in srgb, var(--color-green) 30%, white);
}
swp-employee-status[data-active="false"] {
background: color-mix(in srgb, var(--color-red) 12%, white);
color: var(--color-red);
border: 1px solid color-mix(in srgb, var(--color-red) 30%, white);
}
swp-employee-status .icon {
font-size: var(--font-size-base);
}
/* Inline fact boxes (horizontal baseline alignment) */
swp-fact-boxes-inline {
display: flex;
gap: var(--spacing-12);
margin-top: var(--spacing-1);
flex-wrap: wrap;
}
swp-fact-inline {
display: flex;
align-items: baseline;
gap: var(--spacing-2);
}
swp-fact-inline-value {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
font-family: var(--font-mono);
color: var(--color-text);
}
swp-fact-inline-label {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
/* Edit rows for contenteditable (Grid + Subgrid) */
swp-edit-section {
display: grid;
grid-template-columns: 140px 1fr;
gap: var(--spacing-4);
}
swp-edit-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
}
swp-edit-label {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
swp-edit-value {
font-size: var(--font-size-base);
color: var(--color-text);
padding: var(--spacing-2) var(--spacing-4);
border-radius: var(--radius-sm);
background: var(--color-background-alt);
border: 1px solid transparent;
transition: all var(--transition-fast);
cursor: text;
}
swp-edit-value:hover {
background: var(--color-background);
}
swp-edit-value:focus {
outline: none;
background: var(--color-surface);
border-color: var(--color-teal);
}
/* Section label in cards */
swp-section-label {
display: block;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text);
margin-bottom: var(--spacing-6);
padding-bottom: var(--spacing-4);
border-bottom: 1px solid var(--color-border);
}
/* ===========================================
VIEW CONTAINERS (List/Detail swap)
=========================================== */
swp-employees-list-view {
display: block;
}
swp-employee-detail-view {
display: none;
min-height: calc(100vh - 60px);
}
/* Back link */
swp-back-link {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
cursor: pointer;
transition: color var(--transition-fast);
}
swp-back-link:hover {
color: var(--color-teal);
}
swp-back-link i {
font-size: 16px;
}
/* Detail grid for cards (2-column layout) */
swp-detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-8);
}
swp-detail-grid > div {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
swp-detail-grid swp-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--card-padding);
}
@media (max-width: 900px) {
swp-detail-grid {
grid-template-columns: 1fr;
}
}
/* ===========================================
SCHEDULE GRID (Hours tab)
=========================================== */
swp-schedule-grid {
display: flex;
flex-direction: column;
}
swp-schedule-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-4) 0;
border-bottom: 1px solid var(--color-border);
}
swp-schedule-row:last-child {
border-bottom: none;
}
swp-schedule-row.off {
color: var(--color-text-secondary);
}
swp-schedule-day {
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
}
swp-schedule-time {
font-size: var(--font-size-base);
font-family: var(--font-mono);
color: var(--color-teal);
}
swp-schedule-row.off swp-schedule-time {
color: var(--color-text-secondary);
}
/* ===========================================
SERVICE LIST (Services tab)
=========================================== */
swp-service-list {
display: flex;
flex-direction: column;
}
swp-service-item {
display: grid;
grid-template-columns: 1fr auto auto;
gap: var(--spacing-6);
align-items: center;
padding: var(--spacing-4) 0;
border-bottom: 1px solid var(--color-border);
}
swp-service-item:last-child {
border-bottom: none;
}
swp-service-name {
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
}
swp-service-duration {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
swp-service-price {
font-size: var(--font-size-base);
font-family: var(--font-mono);
color: var(--color-teal);
font-weight: var(--font-weight-medium);
}
/* ===========================================
DOCUMENT LIST (HR tab)
=========================================== */
swp-document-list {
display: flex;
flex-direction: column;
}
swp-document-item {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-4) 0;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
transition: background var(--transition-fast);
}
swp-document-item:last-child {
border-bottom: none;
}
swp-document-item:hover {
background: var(--color-background-alt);
margin: 0 calc(-1 * var(--spacing-3));
padding-left: var(--spacing-3);
padding-right: var(--spacing-3);
border-radius: var(--radius-sm);
}
swp-document-item i {
font-size: 24px;
color: var(--color-red);
}
swp-document-name {
flex: 1;
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
}
swp-document-date {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
/* Notes area */
swp-notes-area {
display: block;
min-height: 100px;
padding: var(--spacing-4);
background: var(--color-background-alt);
border-radius: var(--radius-sm);
font-size: var(--font-size-base);
color: var(--color-text-secondary);
line-height: 1.6;
}
swp-notes-area:focus {
outline: none;
color: var(--color-text);
}
/* ===========================================
RESPONSIVE
=========================================== */
@media (max-width: 1024px) {
swp-employee-table {
grid-template-columns: minmax(180px, 1fr) 100px 120px 100px 40px;
}
}
@media (max-width: 768px) {
swp-employee-table {
grid-template-columns: minmax(160px, 1fr) 90px 110px 90px 40px;
}
swp-employee-cell {
padding: var(--spacing-3) 0;
}
swp-employee-table-header,
swp-employee-row {
padding: 0 var(--spacing-4);
}
swp-users-header {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-4);
}
swp-users-header swp-btn {
width: 100%;
justify-content: center;
}
}

View file

@ -14,6 +14,42 @@ swp-page-container {
padding: var(--page-padding);
}
/* ===========================================
STICKY HEADER (Generic - use for all tabbed pages)
=========================================== */
swp-sticky-header {
display: block;
position: sticky;
top: 0;
z-index: var(--z-sticky);
background: var(--color-surface);
overflow: visible;
/* INGEN padding eller border - det er på swp-header-content */
}
/* Override tab-bar sticky when inside sticky header */
swp-sticky-header swp-tab-bar {
position: static;
top: auto;
}
/* Header content wrapper - HAR padding + border */
swp-header-content {
display: block;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: var(--spacing-10) var(--spacing-12);
}
swp-header-content swp-page-header {
padding: 0;
margin-bottom: 0;
}
swp-header-content swp-stats-row {
margin-bottom: 0;
}
/* ===========================================
PAGE HEADER
=========================================== */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -10,6 +10,7 @@ import { ThemeController } from './modules/theme';
import { SearchController } from './modules/search';
import { LockScreenController } from './modules/lockscreen';
import { CashController } from './modules/cash';
import { EmployeesController } from './modules/employees';
/**
* Main application class
@ -21,6 +22,7 @@ export class App {
readonly search: SearchController;
readonly lockScreen: LockScreenController;
readonly cash: CashController;
readonly employees: EmployeesController;
constructor() {
// Initialize controllers
@ -30,6 +32,7 @@ export class App {
this.search = new SearchController();
this.lockScreen = new LockScreenController(this.drawers);
this.cash = new CashController();
this.employees = new EmployeesController();
}
}

View file

@ -0,0 +1,191 @@
/**
* Employees Controller
*
* Handles content swap between list view and detail view,
* plus tab switching within each view.
* Uses History API for browser back/forward navigation.
*/
export class EmployeesController {
private listView: HTMLElement | null = null;
private detailView: HTMLElement | null = null;
constructor() {
this.listView = document.getElementById('employees-list-view');
this.detailView = document.getElementById('employee-detail-view');
// Only initialize if we're on the employees page
if (!this.listView) return;
this.setupListTabs();
this.setupDetailTabs();
this.setupChevronNavigation();
this.setupBackNavigation();
this.setupHistoryNavigation();
this.restoreStateFromUrl();
}
/**
* Setup popstate listener for browser back/forward
*/
private setupHistoryNavigation(): void {
window.addEventListener('popstate', (e: PopStateEvent) => {
if (e.state?.employeeKey) {
this.showDetailViewInternal(e.state.employeeKey);
} else {
this.showListViewInternal();
}
});
}
/**
* Restore view state from URL on page load
*/
private restoreStateFromUrl(): void {
const hash = window.location.hash;
if (hash.startsWith('#employee-')) {
const employeeKey = hash.substring(1); // Remove #
this.showDetailViewInternal(employeeKey);
}
}
/**
* Setup tab switching for the list view
*/
private setupListTabs(): void {
if (!this.listView) return;
const tabs = this.listView.querySelectorAll<HTMLElement>('swp-tab-bar > swp-tab[data-tab]');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
const targetTab = tab.dataset.tab;
if (targetTab) {
this.switchTab(this.listView!, targetTab);
}
});
});
}
/**
* Setup tab switching for the detail view
*/
private setupDetailTabs(): void {
if (!this.detailView) return;
const tabs = this.detailView.querySelectorAll<HTMLElement>('swp-tab-bar > swp-tab[data-tab]');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
const targetTab = tab.dataset.tab;
if (targetTab) {
this.switchTab(this.detailView!, targetTab);
}
});
});
}
/**
* Switch to a specific tab within a container
*/
private switchTab(container: HTMLElement, targetTab: string): void {
const tabs = container.querySelectorAll<HTMLElement>('swp-tab-bar > swp-tab[data-tab]');
const contents = container.querySelectorAll<HTMLElement>('swp-tab-content[data-tab]');
tabs.forEach(t => {
t.classList.toggle('active', t.dataset.tab === targetTab);
});
contents.forEach(content => {
content.classList.toggle('active', content.dataset.tab === targetTab);
});
}
/**
* Setup row click to show detail view
* Ignores clicks on action buttons
*/
private setupChevronNavigation(): void {
document.addEventListener('click', (e: Event) => {
const target = e.target as HTMLElement;
// Ignore clicks on action buttons
if (target.closest('swp-icon-btn') || target.closest('swp-table-actions')) {
return;
}
const row = target.closest<HTMLElement>('swp-employee-row[data-employee-detail]');
if (row) {
const employeeKey = row.dataset.employeeDetail;
if (employeeKey) {
this.showDetailView(employeeKey);
}
}
});
}
/**
* Setup back button to return to list view
*/
private setupBackNavigation(): void {
document.addEventListener('click', (e: Event) => {
const target = e.target as HTMLElement;
const backLink = target.closest<HTMLElement>('[data-employee-back]');
if (backLink) {
this.showListView();
}
});
}
/**
* Show the detail view and hide list view (with history push)
*/
private showDetailView(employeeKey: string): void {
// Push state to history
history.pushState(
{ employeeKey },
'',
`#${employeeKey}`
);
this.showDetailViewInternal(employeeKey);
}
/**
* Show detail view without modifying history (for popstate)
*/
private showDetailViewInternal(employeeKey: string): void {
if (this.listView && this.detailView) {
this.listView.style.display = 'none';
this.detailView.style.display = 'block';
this.detailView.dataset.employee = employeeKey;
// Reset to first tab
this.switchTab(this.detailView, 'general');
}
}
/**
* Show the list view and hide detail view (with history push)
*/
private showListView(): void {
// Push state to history (clear hash)
history.pushState(
{},
'',
window.location.pathname
);
this.showListViewInternal();
}
/**
* Show list view without modifying history (for popstate)
*/
private showListViewInternal(): void {
if (this.listView && this.detailView) {
this.detailView.style.display = 'none';
this.listView.style.display = 'block';
}
}
}