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">