Refactors employee details and UI controls

Enhances employee hours view with dynamic weekly schedule rendering
Updates toggle slider and theme switch components with improved interactions
Adds more flexible notification and settings configurations for employees

Improves user experience by streamlining UI controls and schedule display
This commit is contained in:
Janus C. H. Knudsen 2026-01-15 16:59:56 +01:00
parent 6746e876d7
commit 545d6606a6
18 changed files with 506 additions and 206 deletions

2
.gitignore vendored
View file

@ -368,3 +368,5 @@ PlanTempus.Application/tmpclaude*
PlanTempus.Application/wwwroot/js/app.js PlanTempus.Application/wwwroot/js/app.js
PlanTempus.Application/wwwroot/js/app.js.map

View file

@ -88,16 +88,6 @@
<swp-toggle-option>@Model.ToggleNo</swp-toggle-option> <swp-toggle-option>@Model.ToggleNo</swp-toggle-option>
</swp-toggle-slider> </swp-toggle-slider>
</swp-toggle-row> </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> <swp-toggle-row>
<div> <div>
<swp-toggle-label>@Model.SettingEditCalendar</swp-toggle-label> <swp-toggle-label>@Model.SettingEditCalendar</swp-toggle-label>
@ -114,38 +104,48 @@
<swp-card> <swp-card>
<swp-section-label>@Model.LabelNotifications</swp-section-label> <swp-section-label>@Model.LabelNotifications</swp-section-label>
<swp-notification-intro>@Model.NotificationsIntro</swp-notification-intro> <swp-notification-intro>@Model.NotificationsIntro</swp-notification-intro>
<swp-checkbox-list> <swp-toggle-row>
<swp-checkbox-row class="checked"> <swp-toggle-label>@Model.SettingSmsReminders</swp-toggle-label>
<swp-checkbox-box> <swp-toggle-slider data-value="yes">
<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-toggle-option>@Model.ToggleYes</swp-toggle-option>
</swp-checkbox-box> <swp-toggle-option>@Model.ToggleNo</swp-toggle-option>
<swp-checkbox-text>@Model.NotifOnlineBooking</swp-checkbox-text> </swp-toggle-slider>
</swp-checkbox-row> </swp-toggle-row>
<swp-checkbox-row class="checked"> <swp-toggle-row>
<swp-checkbox-box> <swp-toggle-label>@Model.NotifOnlineBooking</swp-toggle-label>
<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-toggle-slider data-value="yes">
</swp-checkbox-box> <swp-toggle-option>@Model.ToggleYes</swp-toggle-option>
<swp-checkbox-text>@Model.NotifManualBooking</swp-checkbox-text> <swp-toggle-option>@Model.ToggleNo</swp-toggle-option>
</swp-checkbox-row> </swp-toggle-slider>
<swp-checkbox-row> </swp-toggle-row>
<swp-checkbox-box> <swp-toggle-row>
<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-toggle-label>@Model.NotifManualBooking</swp-toggle-label>
</swp-checkbox-box> <swp-toggle-slider data-value="yes">
<swp-checkbox-text>@Model.NotifCancellation</swp-checkbox-text> <swp-toggle-option>@Model.ToggleYes</swp-toggle-option>
</swp-checkbox-row> <swp-toggle-option>@Model.ToggleNo</swp-toggle-option>
<swp-checkbox-row> </swp-toggle-slider>
<swp-checkbox-box> </swp-toggle-row>
<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-toggle-row>
</swp-checkbox-box> <swp-toggle-label>@Model.NotifCancellation</swp-toggle-label>
<swp-checkbox-text>@Model.NotifWaitlist</swp-checkbox-text> <swp-toggle-slider data-value="no">
</swp-checkbox-row> <swp-toggle-option>@Model.ToggleYes</swp-toggle-option>
<swp-checkbox-row class="checked"> <swp-toggle-option>@Model.ToggleNo</swp-toggle-option>
<swp-checkbox-box> </swp-toggle-slider>
<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-toggle-row>
</swp-checkbox-box> <swp-toggle-row>
<swp-checkbox-text>@Model.NotifDailySummary</swp-checkbox-text> <swp-toggle-label>@Model.NotifWaitlist</swp-toggle-label>
</swp-checkbox-row> <swp-toggle-slider data-value="no">
</swp-checkbox-list> <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>
<swp-toggle-label>@Model.NotifDailySummary</swp-toggle-label>
<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> </swp-card>
</div> </div>
</swp-detail-grid> </swp-detail-grid>

View file

@ -1,37 +1,36 @@
@model PlanTempus.Application.Features.Employees.Components.EmployeeDetailHoursViewModel @model PlanTempus.Application.Features.Employees.Components.EmployeeDetailHoursViewModel
<swp-detail-grid> @{
<swp-card> string GetBadgeClass(string status) => status switch
<swp-section-label>@Model.LabelWeeklySchedule</swp-section-label> {
<swp-schedule-grid> "work" => "",
<swp-schedule-row> "off" => "off",
<swp-schedule-day>@Model.LabelMonday</swp-schedule-day> "vacation" => "vacation",
<swp-schedule-time>09:00 - 17:00</swp-schedule-time> "sick" => "sick",
</swp-schedule-row> _ => "off"
<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-scroll>
<swp-schedule-row> <swp-schedule-table class="hours-view">
<swp-schedule-day>@Model.LabelWednesday</swp-schedule-day> <!-- Header row -->
<swp-schedule-time>09:00 - 17:00</swp-schedule-time> <swp-schedule-cell class="header week-number"></swp-schedule-cell>
</swp-schedule-row> @foreach (var dayName in Model.DayNames)
<swp-schedule-row> {
<swp-schedule-day>@Model.LabelThursday</swp-schedule-day> <swp-schedule-cell class="header"><swp-day-name>@dayName</swp-day-name></swp-schedule-cell>
<swp-schedule-time>09:00 - 19:00</swp-schedule-time> }
</swp-schedule-row>
<swp-schedule-row> <!-- Week rows -->
<swp-schedule-day>@Model.LabelFriday</swp-schedule-day> @foreach (var week in Model.Weeks)
<swp-schedule-time>09:00 - 16:00</swp-schedule-time> {
</swp-schedule-row> <swp-schedule-cell class="employee week-label">
<swp-schedule-row class="off"> <swp-employee-name>Uge @week.WeekNumber</swp-employee-name>
<swp-schedule-day>@Model.LabelSaturday</swp-schedule-day> <swp-employee-hours>@week.TotalHours @Model.LabelHours</swp-employee-hours>
<swp-schedule-time>Fri</swp-schedule-time> </swp-schedule-cell>
</swp-schedule-row> @foreach (var day in week.Days)
<swp-schedule-row class="off"> {
<swp-schedule-day>@Model.LabelSunday</swp-schedule-day> <swp-schedule-cell class="day"><swp-time-badge class="@GetBadgeClass(day.Status)">@day.Display</swp-time-badge></swp-schedule-cell>
<swp-schedule-time>Fri</swp-schedule-time> }
</swp-schedule-row> }
</swp-schedule-grid> </swp-schedule-table>
</swp-card> </swp-schedule-scroll>
</swp-detail-grid>

View file

@ -1,3 +1,4 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using PlanTempus.Application.Features.Localization.Services; using PlanTempus.Application.Features.Localization.Services;
@ -6,38 +7,132 @@ namespace PlanTempus.Application.Features.Employees.Components;
public class EmployeeDetailHoursViewComponent : ViewComponent public class EmployeeDetailHoursViewComponent : ViewComponent
{ {
private readonly ILocalizationService _localization; private readonly ILocalizationService _localization;
private readonly IWebHostEnvironment _environment;
public EmployeeDetailHoursViewComponent(ILocalizationService localization) public EmployeeDetailHoursViewComponent(ILocalizationService localization, IWebHostEnvironment environment)
{ {
_localization = localization; _localization = localization;
_environment = environment;
} }
public IViewComponentResult Invoke(string key) public IViewComponentResult Invoke(string key)
{ {
var weekSchedule = LoadMockData();
var employee = weekSchedule.Employees.FirstOrDefault(e => e.EmployeeId == key);
var weeks = GenerateWeeks(weekSchedule, employee);
var model = new EmployeeDetailHoursViewModel var model = new EmployeeDetailHoursViewModel
{ {
LabelWeeklySchedule = _localization.Get("employees.detail.hours.weekly"), EmployeeId = key,
LabelMonday = _localization.Get("employees.detail.hours.monday"), Weeks = weeks,
LabelTuesday = _localization.Get("employees.detail.hours.tuesday"), DayNames = new[] { "Mandag", "Tirsdag", "Onsdag", "Torsdag", "Fredag", "Lørdag", "Søndag" },
LabelWednesday = _localization.Get("employees.detail.hours.wednesday"), LabelHours = _localization.Get("employees.detail.hours.label")
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); return View(model);
} }
private WeekScheduleData LoadMockData()
{
var jsonPath = Path.Combine(_environment.ContentRootPath, "Features", "Employees", "Data", "workScheduleMock.json");
var json = System.IO.File.ReadAllText(jsonPath);
return JsonSerializer.Deserialize<WeekScheduleData>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
})!;
}
private List<WeekHoursData> GenerateWeeks(WeekScheduleData weekSchedule, EmployeeScheduleData? employee)
{
var weeks = new List<WeekHoursData>();
var startDate = DateTime.Parse(weekSchedule.StartDate);
// Generate 6 weeks of data (current week + 5 more)
for (int w = 0; w < 6; w++)
{
var weekStart = startDate.AddDays(w * 7);
var weekNumber = GetWeekNumber(weekStart);
var days = new List<DayHoursData>();
var totalMinutes = 0;
for (int d = 0; d < 7; d++)
{
var date = weekStart.AddDays(d);
var dateKey = date.ToString("yyyy-MM-dd");
var shift = employee?.Schedule.GetValueOrDefault(dateKey);
string status = "off";
string display = "—";
if (shift != null)
{
status = shift.Status;
if (shift.Status == "work" && shift.Start != null && shift.End != null)
{
display = $"{shift.Start} - {shift.End}";
totalMinutes += CalculateMinutes(shift.Start, shift.End);
}
else if (shift.Status == "vacation")
{
display = "Ferie";
}
else if (shift.Status == "sick")
{
display = "Syg";
}
}
days.Add(new DayHoursData
{
Date = dateKey,
Status = status,
Display = display
});
}
weeks.Add(new WeekHoursData
{
WeekNumber = weekNumber,
TotalHours = totalMinutes / 60,
Days = days
});
}
return weeks;
}
private int GetWeekNumber(DateTime date)
{
var cal = System.Globalization.CultureInfo.CurrentCulture.Calendar;
return cal.GetWeekOfYear(date, System.Globalization.CalendarWeekRule.FirstFourDayWeek, DayOfWeek.Monday);
}
private int CalculateMinutes(string start, string end)
{
var startTime = TimeSpan.Parse(start);
var endTime = TimeSpan.Parse(end);
return (int)(endTime - startTime).TotalMinutes;
}
} }
public class EmployeeDetailHoursViewModel public class EmployeeDetailHoursViewModel
{ {
public required string LabelWeeklySchedule { get; init; } public required string EmployeeId { get; init; }
public required string LabelMonday { get; init; } public required List<WeekHoursData> Weeks { get; init; }
public required string LabelTuesday { get; init; } public required string[] DayNames { get; init; }
public required string LabelWednesday { get; init; } public required string LabelHours { get; init; }
public required string LabelThursday { get; init; } }
public required string LabelFriday { get; init; }
public required string LabelSaturday { get; init; } public class WeekHoursData
public required string LabelSunday { get; init; } {
public int WeekNumber { get; init; }
public int TotalHours { get; init; }
public required List<DayHoursData> Days { get; init; }
}
public class DayHoursData
{
public required string Date { get; init; }
public required string Status { get; init; }
public required string Display { get; init; }
} }

View file

@ -67,7 +67,7 @@
data-employee-id="@employee.EmployeeId" data-employee-id="@employee.EmployeeId"
data-date="@day.Date" data-date="@day.Date"
data-day="@day.ShortDayName"> data-day="@day.ShortDayName">
<swp-time-display class="@GetTimeClass(shift)">@GetTimeDisplay(shift)</swp-time-display> <swp-time-badge class="@GetTimeClass(shift)">@GetTimeDisplay(shift)</swp-time-badge>
</swp-schedule-cell> </swp-schedule-cell>
} }
} }

View file

@ -3,10 +3,10 @@
"year": 2025, "year": 2025,
"startDate": "2025-12-23", "startDate": "2025-12-23",
"endDate": "2025-12-29", "endDate": "2025-12-29",
"closedDays": ["2025-12-25"], "closedDays": ["2025-12-25", "2026-01-01"],
"employees": [ "employees": [
{ {
"employeeId": "emp-1", "employeeId": "employee-1",
"name": "Anna Sørensen", "name": "Anna Sørensen",
"weeklyHours": 32, "weeklyHours": 32,
"schedule": { "schedule": {
@ -16,11 +16,39 @@
"2025-12-26": { "status": "off" }, "2025-12-26": { "status": "off" },
"2025-12-27": { "status": "work", "start": "09:00", "end": "17:00" }, "2025-12-27": { "status": "work", "start": "09:00", "end": "17:00" },
"2025-12-28": { "status": "work", "start": "10:00", "end": "14:00" }, "2025-12-28": { "status": "work", "start": "10:00", "end": "14:00" },
"2025-12-29": { "status": "off" } "2025-12-29": { "status": "off" },
"2025-12-30": { "status": "work", "start": "09:00", "end": "17:00" },
"2025-12-31": { "status": "work", "start": "09:00", "end": "14:00" },
"2026-01-01": { "status": "off" },
"2026-01-02": { "status": "work", "start": "09:00", "end": "17:00" },
"2026-01-03": { "status": "work", "start": "09:00", "end": "17:00" },
"2026-01-04": { "status": "work", "start": "10:00", "end": "14:00" },
"2026-01-05": { "status": "off" },
"2026-01-06": { "status": "work", "start": "09:00", "end": "17:00" },
"2026-01-07": { "status": "work", "start": "09:00", "end": "17:00" },
"2026-01-08": { "status": "work", "start": "09:00", "end": "17:00" },
"2026-01-09": { "status": "work", "start": "09:00", "end": "17:00" },
"2026-01-10": { "status": "off" },
"2026-01-11": { "status": "off" },
"2026-01-12": { "status": "off" },
"2026-01-13": { "status": "work", "start": "09:00", "end": "17:00" },
"2026-01-14": { "status": "work", "start": "09:00", "end": "17:00" },
"2026-01-15": { "status": "work", "start": "09:00", "end": "17:00" },
"2026-01-16": { "status": "work", "start": "09:00", "end": "17:00" },
"2026-01-17": { "status": "work", "start": "09:00", "end": "15:00" },
"2026-01-18": { "status": "off" },
"2026-01-19": { "status": "off" },
"2026-01-20": { "status": "vacation" },
"2026-01-21": { "status": "vacation" },
"2026-01-22": { "status": "vacation" },
"2026-01-23": { "status": "vacation" },
"2026-01-24": { "status": "vacation" },
"2026-01-25": { "status": "off" },
"2026-01-26": { "status": "off" }
} }
}, },
{ {
"employeeId": "emp-2", "employeeId": "employee-2",
"name": "Mette Jensen", "name": "Mette Jensen",
"weeklyHours": 40, "weeklyHours": 40,
"schedule": { "schedule": {
@ -30,11 +58,39 @@
"2025-12-26": { "status": "vacation" }, "2025-12-26": { "status": "vacation" },
"2025-12-27": { "status": "vacation" }, "2025-12-27": { "status": "vacation" },
"2025-12-28": { "status": "off" }, "2025-12-28": { "status": "off" },
"2025-12-29": { "status": "off" } "2025-12-29": { "status": "off" },
"2025-12-30": { "status": "vacation" },
"2025-12-31": { "status": "vacation" },
"2026-01-01": { "status": "off" },
"2026-01-02": { "status": "work", "start": "10:00", "end": "18:00" },
"2026-01-03": { "status": "work", "start": "10:00", "end": "18:00" },
"2026-01-04": { "status": "off" },
"2026-01-05": { "status": "off" },
"2026-01-06": { "status": "work", "start": "10:00", "end": "18:00" },
"2026-01-07": { "status": "work", "start": "10:00", "end": "18:00" },
"2026-01-08": { "status": "work", "start": "10:00", "end": "18:00" },
"2026-01-09": { "status": "work", "start": "10:00", "end": "18:00" },
"2026-01-10": { "status": "work", "start": "10:00", "end": "18:00" },
"2026-01-11": { "status": "off" },
"2026-01-12": { "status": "off" },
"2026-01-13": { "status": "work", "start": "10:00", "end": "18:00" },
"2026-01-14": { "status": "work", "start": "10:00", "end": "18:00" },
"2026-01-15": { "status": "work", "start": "10:00", "end": "18:00" },
"2026-01-16": { "status": "work", "start": "10:00", "end": "18:00" },
"2026-01-17": { "status": "work", "start": "10:00", "end": "18:00" },
"2026-01-18": { "status": "off" },
"2026-01-19": { "status": "off" },
"2026-01-20": { "status": "work", "start": "10:00", "end": "18:00" },
"2026-01-21": { "status": "work", "start": "10:00", "end": "18:00" },
"2026-01-22": { "status": "work", "start": "10:00", "end": "18:00" },
"2026-01-23": { "status": "work", "start": "10:00", "end": "18:00" },
"2026-01-24": { "status": "work", "start": "10:00", "end": "18:00" },
"2026-01-25": { "status": "off" },
"2026-01-26": { "status": "off" }
} }
}, },
{ {
"employeeId": "emp-3", "employeeId": "employee-3",
"name": "Louise Nielsen", "name": "Louise Nielsen",
"weeklyHours": 37, "weeklyHours": 37,
"schedule": { "schedule": {
@ -44,11 +100,39 @@
"2025-12-26": { "status": "off" }, "2025-12-26": { "status": "off" },
"2025-12-27": { "status": "work", "start": "09:00", "end": "17:00" }, "2025-12-27": { "status": "work", "start": "09:00", "end": "17:00" },
"2025-12-28": { "status": "work", "start": "09:00", "end": "14:00" }, "2025-12-28": { "status": "work", "start": "09:00", "end": "14:00" },
"2025-12-29": { "status": "off" } "2025-12-29": { "status": "off" },
"2025-12-30": { "status": "work", "start": "09:00", "end": "17:00" },
"2025-12-31": { "status": "work", "start": "09:00", "end": "13:00" },
"2026-01-01": { "status": "off" },
"2026-01-02": { "status": "work", "start": "09:00", "end": "17:00" },
"2026-01-03": { "status": "work", "start": "09:00", "end": "17:00" },
"2026-01-04": { "status": "off" },
"2026-01-05": { "status": "off" },
"2026-01-06": { "status": "work", "start": "09:00", "end": "17:00" },
"2026-01-07": { "status": "work", "start": "09:00", "end": "17:00" },
"2026-01-08": { "status": "sick" },
"2026-01-09": { "status": "sick" },
"2026-01-10": { "status": "work", "start": "09:00", "end": "17:00" },
"2026-01-11": { "status": "off" },
"2026-01-12": { "status": "off" },
"2026-01-13": { "status": "work", "start": "09:00", "end": "17:00" },
"2026-01-14": { "status": "work", "start": "09:00", "end": "17:00" },
"2026-01-15": { "status": "work", "start": "09:00", "end": "17:00" },
"2026-01-16": { "status": "work", "start": "09:00", "end": "17:00" },
"2026-01-17": { "status": "work", "start": "09:00", "end": "17:00" },
"2026-01-18": { "status": "off" },
"2026-01-19": { "status": "off" },
"2026-01-20": { "status": "work", "start": "09:00", "end": "17:00" },
"2026-01-21": { "status": "work", "start": "09:00", "end": "17:00" },
"2026-01-22": { "status": "work", "start": "09:00", "end": "17:00" },
"2026-01-23": { "status": "work", "start": "09:00", "end": "17:00" },
"2026-01-24": { "status": "work", "start": "09:00", "end": "17:00" },
"2026-01-25": { "status": "off" },
"2026-01-26": { "status": "off" }
} }
}, },
{ {
"employeeId": "emp-4", "employeeId": "employee-4",
"name": "Katrine Pedersen", "name": "Katrine Pedersen",
"weeklyHours": 24, "weeklyHours": 24,
"schedule": { "schedule": {
@ -58,11 +142,39 @@
"2025-12-26": { "status": "off" }, "2025-12-26": { "status": "off" },
"2025-12-27": { "status": "work", "start": "12:00", "end": "20:00" }, "2025-12-27": { "status": "work", "start": "12:00", "end": "20:00" },
"2025-12-28": { "status": "work", "start": "10:00", "end": "18:00" }, "2025-12-28": { "status": "work", "start": "10:00", "end": "18:00" },
"2025-12-29": { "status": "off" } "2025-12-29": { "status": "off" },
"2025-12-30": { "status": "work", "start": "12:00", "end": "20:00" },
"2025-12-31": { "status": "off" },
"2026-01-01": { "status": "off" },
"2026-01-02": { "status": "work", "start": "12:00", "end": "20:00" },
"2026-01-03": { "status": "work", "start": "12:00", "end": "20:00" },
"2026-01-04": { "status": "work", "start": "10:00", "end": "18:00" },
"2026-01-05": { "status": "off" },
"2026-01-06": { "status": "off" },
"2026-01-07": { "status": "work", "start": "12:00", "end": "20:00" },
"2026-01-08": { "status": "work", "start": "12:00", "end": "20:00" },
"2026-01-09": { "status": "off" },
"2026-01-10": { "status": "work", "start": "12:00", "end": "20:00" },
"2026-01-11": { "status": "work", "start": "10:00", "end": "18:00" },
"2026-01-12": { "status": "off" },
"2026-01-13": { "status": "off" },
"2026-01-14": { "status": "work", "start": "12:00", "end": "20:00" },
"2026-01-15": { "status": "work", "start": "12:00", "end": "20:00" },
"2026-01-16": { "status": "off" },
"2026-01-17": { "status": "work", "start": "12:00", "end": "20:00" },
"2026-01-18": { "status": "work", "start": "10:00", "end": "18:00" },
"2026-01-19": { "status": "off" },
"2026-01-20": { "status": "off" },
"2026-01-21": { "status": "work", "start": "12:00", "end": "20:00" },
"2026-01-22": { "status": "work", "start": "12:00", "end": "20:00" },
"2026-01-23": { "status": "off" },
"2026-01-24": { "status": "work", "start": "12:00", "end": "20:00" },
"2026-01-25": { "status": "work", "start": "10:00", "end": "18:00" },
"2026-01-26": { "status": "off" }
} }
}, },
{ {
"employeeId": "emp-5", "employeeId": "employee-5",
"name": "Sofie Andersen", "name": "Sofie Andersen",
"weeklyHours": 20, "weeklyHours": 20,
"schedule": { "schedule": {
@ -72,7 +184,35 @@
"2025-12-26": { "status": "off" }, "2025-12-26": { "status": "off" },
"2025-12-27": { "status": "work", "start": "09:00", "end": "15:00" }, "2025-12-27": { "status": "work", "start": "09:00", "end": "15:00" },
"2025-12-28": { "status": "off" }, "2025-12-28": { "status": "off" },
"2025-12-29": { "status": "off" } "2025-12-29": { "status": "off" },
"2025-12-30": { "status": "work", "start": "09:00", "end": "15:00" },
"2025-12-31": { "status": "off" },
"2026-01-01": { "status": "off" },
"2026-01-02": { "status": "work", "start": "09:00", "end": "15:00" },
"2026-01-03": { "status": "work", "start": "09:00", "end": "15:00" },
"2026-01-04": { "status": "off" },
"2026-01-05": { "status": "off" },
"2026-01-06": { "status": "work", "start": "09:00", "end": "15:00" },
"2026-01-07": { "status": "work", "start": "09:00", "end": "15:00" },
"2026-01-08": { "status": "work", "start": "09:00", "end": "15:00" },
"2026-01-09": { "status": "off" },
"2026-01-10": { "status": "off" },
"2026-01-11": { "status": "off" },
"2026-01-12": { "status": "off" },
"2026-01-13": { "status": "work", "start": "09:00", "end": "15:00" },
"2026-01-14": { "status": "work", "start": "09:00", "end": "15:00" },
"2026-01-15": { "status": "work", "start": "09:00", "end": "15:00" },
"2026-01-16": { "status": "off" },
"2026-01-17": { "status": "off" },
"2026-01-18": { "status": "off" },
"2026-01-19": { "status": "off" },
"2026-01-20": { "status": "work", "start": "09:00", "end": "15:00" },
"2026-01-21": { "status": "work", "start": "09:00", "end": "15:00" },
"2026-01-22": { "status": "work", "start": "09:00", "end": "15:00" },
"2026-01-23": { "status": "off" },
"2026-01-24": { "status": "off" },
"2026-01-25": { "status": "off" },
"2026-01-26": { "status": "off" }
} }
} }
] ]

View file

@ -317,6 +317,7 @@
"rating": "rating", "rating": "rating",
"employedsince": "ansat siden", "employedsince": "ansat siden",
"hours": { "hours": {
"label": "timer",
"weekly": "Ugentlig arbejdstid", "weekly": "Ugentlig arbejdstid",
"monday": "Mandag", "monday": "Mandag",
"tuesday": "Tirsdag", "tuesday": "Tirsdag",
@ -409,8 +410,8 @@
"desc": "Kunder kan vælge denne medarbejder" "desc": "Kunder kan vælge denne medarbejder"
}, },
"smsreminders": { "smsreminders": {
"label": "Modtag SMS-påmindelser", "label": "Få notifikation via App'en om nye bookinger",
"desc": "Få besked om nye bookinger" "desc": ""
}, },
"editcalendar": { "editcalendar": {
"label": "Kan redigere egen kalender", "label": "Kan redigere egen kalender",
@ -419,12 +420,12 @@
}, },
"notifications": { "notifications": {
"label": "Notifikationer", "label": "Notifikationer",
"intro": "Vælg hvilke email-notifikationer medarbejderen skal modtage.", "intro": "Vælg hvilke notifikationer der skal sendes.",
"onlinebooking": "Modtag email ved online booking", "onlinebooking": "Email ved online booking",
"manualbooking": "Modtag email ved manuel booking", "manualbooking": "Email ved manuel booking",
"cancellation": "Modtag email ved aflysning", "cancellation": "Email ved aflysning",
"waitlist": "Modtag email ved opskrivning til venteliste", "waitlist": "Email ved opskrivning til venteliste",
"dailysummary": "Modtag daglig oversigt over morgendagens bookinger" "dailysummary": "Email med daglig oversigt"
} }
} }
} }

View file

@ -317,6 +317,7 @@
"rating": "rating", "rating": "rating",
"employedsince": "employed since", "employedsince": "employed since",
"hours": { "hours": {
"label": "hours",
"weekly": "Weekly working hours", "weekly": "Weekly working hours",
"monday": "Monday", "monday": "Monday",
"tuesday": "Tuesday", "tuesday": "Tuesday",
@ -409,8 +410,8 @@
"desc": "Customers can select this employee" "desc": "Customers can select this employee"
}, },
"smsreminders": { "smsreminders": {
"label": "Receive SMS reminders", "label": "Get notified via the App about new bookings",
"desc": "Get notified about new bookings" "desc": ""
}, },
"editcalendar": { "editcalendar": {
"label": "Can edit own calendar", "label": "Can edit own calendar",
@ -419,12 +420,12 @@
}, },
"notifications": { "notifications": {
"label": "Notifications", "label": "Notifications",
"intro": "Choose which email notifications the employee should receive.", "intro": "Choose which notifications to send.",
"onlinebooking": "Receive email for online booking", "onlinebooking": "Email on online booking",
"manualbooking": "Receive email for manual booking", "manualbooking": "Email on manual booking",
"cancellation": "Receive email for cancellation", "cancellation": "Email on cancellation",
"waitlist": "Receive email for waitlist signup", "waitlist": "Email on waitlist signup",
"dailysummary": "Receive daily summary of tomorrow's bookings" "dailysummary": "Email with daily summary"
} }
} }
} }

View file

@ -35,7 +35,7 @@
</swp-theme-label> </swp-theme-label>
<swp-toggle-switch id="themeToggle"> <swp-toggle-switch id="themeToggle">
<input type="checkbox" id="themeCheckbox"> <input type="checkbox" id="themeCheckbox">
<swp-toggle-slider></swp-toggle-slider> <swp-toggle-track></swp-toggle-track>
</swp-toggle-switch> </swp-toggle-switch>
</swp-theme-toggle> </swp-theme-toggle>
</swp-drawer-content> </swp-drawer-content>

View file

@ -35,7 +35,7 @@
</swp-theme-label> </swp-theme-label>
<swp-toggle-switch id="themeToggle"> <swp-toggle-switch id="themeToggle">
<input type="checkbox" id="themeCheckbox"> <input type="checkbox" id="themeCheckbox">
<swp-toggle-slider></swp-toggle-slider> <swp-toggle-track></swp-toggle-track>
</swp-toggle-switch> </swp-toggle-switch>
</swp-theme-toggle> </swp-theme-toggle>
</swp-drawer-content> </swp-drawer-content>

View file

@ -1 +0,0 @@
/c/Users/Janus Knudsen/source/swp-repos/PlanTempus/PlanTempus.Application

View file

@ -35,7 +35,7 @@ swp-toggle-slider {
display: inline-flex; display: inline-flex;
width: fit-content; width: fit-content;
background: var(--color-background); background: var(--color-background);
border-radius: var(--radius-md); border-radius: 6px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
overflow: hidden; overflow: hidden;
position: relative; position: relative;
@ -48,36 +48,71 @@ swp-toggle-slider::before {
left: 2px; left: 2px;
width: calc(50% - 4px); width: calc(50% - 4px);
height: calc(100% - 4px); height: calc(100% - 4px);
background: var(--bg-green-strong); background: color-mix(in srgb, var(--color-green) 18%, white);
border-radius: var(--radius-sm); border-radius: 4px;
transition: transform 200ms ease, background 200ms ease; transition: transform 200ms ease, background 200ms ease;
} }
swp-toggle-slider[data-value="no"]::before { swp-toggle-slider[data-value="no"]::before {
transform: translateX(100%); transform: translateX(calc(100% + 4px));
background: var(--bg-red-strong); background: color-mix(in srgb, var(--color-red) 18%, white);
} }
swp-toggle-option { swp-toggle-option {
position: relative; position: relative;
z-index: 1; z-index: 1;
padding: var(--spacing-2) var(--spacing-5); padding: 5px 16px;
font-size: var(--font-size-sm); font-size: 12px;
font-weight: var(--font-weight-medium); font-weight: 500;
color: var(--color-text-secondary); color: var(--color-text-secondary);
cursor: pointer; cursor: pointer;
transition: color var(--transition-fast); transition: color 150ms ease;
user-select: none; user-select: none;
} }
swp-toggle-slider[data-value="yes"] swp-toggle-option:first-child { swp-toggle-slider[data-value="yes"] swp-toggle-option:first-child {
color: var(--color-green); color: var(--color-green);
font-weight: var(--font-weight-semibold); font-weight: 600;
} }
swp-toggle-slider[data-value="no"] swp-toggle-option:last-child { swp-toggle-slider[data-value="no"] swp-toggle-option:last-child {
color: var(--color-red); color: var(--color-red);
font-weight: var(--font-weight-semibold); font-weight: 600;
}
/* ===========================================
TOGGLE OPTIONS (Tab-style selector)
=========================================== */
swp-toggle-options {
display: flex;
gap: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
overflow: hidden;
swp-toggle-option {
flex: 1;
padding: 10px 16px;
text-align: center;
font-size: var(--font-size-sm);
cursor: pointer;
background: var(--color-surface);
border-right: 1px solid var(--color-border);
transition: all var(--transition-fast);
&:last-child {
border-right: none;
}
&:hover {
background: var(--color-background-alt);
}
&.selected {
background: var(--color-teal);
color: white;
}
}
} }
/* =========================================== /* ===========================================

View file

@ -227,7 +227,7 @@ swp-toggle-switch input {
height: 0; height: 0;
} }
swp-toggle-slider { swp-toggle-track {
position: absolute; position: absolute;
cursor: pointer; cursor: pointer;
inset: 0; inset: 0;
@ -236,7 +236,7 @@ swp-toggle-slider {
transition: background var(--transition-fast); transition: background var(--transition-fast);
} }
swp-toggle-slider::before { swp-toggle-track::before {
content: ''; content: '';
position: absolute; position: absolute;
width: 18px; width: 18px;
@ -248,11 +248,11 @@ swp-toggle-slider::before {
transition: transform var(--transition-fast); transition: transform var(--transition-fast);
} }
swp-toggle-switch input:checked + swp-toggle-slider { swp-toggle-switch input:checked + swp-toggle-track {
background: var(--color-teal); background: var(--color-teal);
} }
swp-toggle-switch input:checked + swp-toggle-slider::before { swp-toggle-switch input:checked + swp-toggle-track::before {
transform: translateX(20px); transform: translateX(20px);
} }

View file

@ -889,11 +889,10 @@ swp-schedule-scroll {
overflow-x: auto; overflow-x: auto;
} }
/* Når drawer er åben: page-container styles (animation via JS) */ /* Når drawer er åben: page-container styles (padding animeres via JS) */
body.schedule-drawer-open swp-tab-content[data-tab="schedule"] swp-page-container { body.schedule-drawer-open swp-tab-content[data-tab="schedule"] swp-page-container {
max-width: none; max-width: none;
margin: 0; margin: 0;
padding-right: var(--drawer-width, 420px);
} }
/* =========================================== /* ===========================================
@ -975,7 +974,7 @@ swp-schedule-cell.day.closed-day {
border-left: 2px solid #f59e0b; border-left: 2px solid #f59e0b;
border-right: 2px solid #f59e0b; border-right: 2px solid #f59e0b;
swp-time-display { swp-time-badge {
opacity: 0.5; opacity: 0.5;
} }
} }
@ -1011,13 +1010,13 @@ swp-day-date {
} }
/* Time display variants */ /* Time display variants */
swp-time-display { swp-time-badge {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 12px; font-size: 12px;
font-weight: var(--font-weight-medium); font-weight: 500;
padding: 4px 8px; padding: 4px 8px;
border-radius: 4px; border-radius: 4px;
background: var(--bg-teal-light); background: color-mix(in srgb, var(--color-teal) 10%, white);
color: var(--color-text); color: var(--color-text);
white-space: nowrap; white-space: nowrap;
min-width: 90px; min-width: 90px;
@ -1025,22 +1024,22 @@ swp-time-display {
display: inline-block; display: inline-block;
} }
swp-time-display.off { swp-time-badge.off {
background: transparent; background: transparent;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
swp-time-display.off.off-override { swp-time-badge.off.off-override {
background: color-mix(in srgb, #7c3aed 12%, white); background: color-mix(in srgb, #7c3aed 12%, white);
color: #6d28d9; color: #6d28d9;
} }
swp-time-display.vacation { swp-time-badge.vacation {
background: color-mix(in srgb, #f59e0b 15%, white); background: color-mix(in srgb, #f59e0b 15%, white);
color: #b45309; color: #b45309;
} }
swp-time-display.sick { swp-time-badge.sick {
background: color-mix(in srgb, #ef4444 15%, white); background: color-mix(in srgb, #ef4444 15%, white);
color: #dc2626; color: #dc2626;
} }
@ -1222,38 +1221,6 @@ swp-time-range-duration {
white-space: nowrap; white-space: nowrap;
} }
/* Toggle options (Enkelt/Gentagelse) */
swp-toggle-options {
display: flex;
gap: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
overflow: hidden;
}
swp-toggle-option {
flex: 1;
padding: 10px 16px;
text-align: center;
font-size: var(--font-size-sm);
cursor: pointer;
background: var(--color-surface);
border-right: 1px solid var(--color-border);
transition: all var(--transition-fast);
&:last-child {
border-right: none;
}
&:hover {
background: var(--color-background-alt);
}
&.selected {
background: var(--color-teal);
color: white;
}
}
/* Schedule drawer employee display */ /* Schedule drawer employee display */
swp-employee-display { swp-employee-display {

View file

@ -11,6 +11,7 @@ import { SearchController } from './modules/search';
import { LockScreenController } from './modules/lockscreen'; import { LockScreenController } from './modules/lockscreen';
import { CashController } from './modules/cash'; import { CashController } from './modules/cash';
import { EmployeesController } from './modules/employees'; import { EmployeesController } from './modules/employees';
import { ControlsController } from './modules/controls';
/** /**
* Main application class * Main application class
@ -23,6 +24,7 @@ export class App {
readonly lockScreen: LockScreenController; readonly lockScreen: LockScreenController;
readonly cash: CashController; readonly cash: CashController;
readonly employees: EmployeesController; readonly employees: EmployeesController;
readonly controls: ControlsController;
constructor() { constructor() {
// Initialize controllers // Initialize controllers
@ -33,6 +35,7 @@ export class App {
this.lockScreen = new LockScreenController(this.drawers); this.lockScreen = new LockScreenController(this.drawers);
this.cash = new CashController(); this.cash = new CashController();
this.employees = new EmployeesController(); this.employees = new EmployeesController();
this.controls = new ControlsController();
} }
} }

View file

@ -0,0 +1,36 @@
/**
* Controls Module
*
* Handles generic UI controls functionality:
* - Toggle sliders (Ja/Nej switches)
*/
/**
* Controller for generic UI controls
*/
export class ControlsController {
constructor() {
this.initToggleSliders();
}
/**
* Initialize all toggle sliders on the page
* Toggle slider: Ja/Nej button switch with data-value attribute
* Clicking anywhere on the slider toggles the value
*/
private initToggleSliders(): void {
document.querySelectorAll('swp-toggle-slider').forEach(slider => {
slider.addEventListener('click', () => {
const el = slider as HTMLElement;
const newValue = el.dataset.value === 'yes' ? 'no' : 'yes';
el.dataset.value = newValue;
// Dispatch custom event for listeners
slider.dispatchEvent(new CustomEvent('toggle', {
bubbles: true,
detail: { value: newValue }
}));
});
});
}
}

View file

@ -1028,55 +1028,66 @@ class ScheduleController {
* Open the schedule drawer (no overlay - user can still interact with table) * Open the schedule drawer (no overlay - user can still interact with table)
*/ */
private openDrawer(): void { private openDrawer(): void {
// Lås tabelbredde før drawer åbner for at undgå "hop" const container = document.querySelector('swp-tab-content[data-tab="schedule"] swp-page-container') as HTMLElement;
const table = document.getElementById('scheduleTable'); const table = document.getElementById('scheduleTable');
// Gem nuværende padding FØR klasser tilføjes
const startPadding = container ? getComputedStyle(container).paddingRight : '0px';
// Lås tabelbredde før drawer åbner for at undgå "hop"
if (table) { if (table) {
const rect = table.getBoundingClientRect(); const rect = table.getBoundingClientRect();
table.style.width = `${rect.width}px`; table.style.width = `${rect.width}px`;
} }
// Animate container med Web Animations API // Tilføj klasser med det samme (maxWidth og margin ændres instant)
const container = document.querySelector('swp-tab-content[data-tab="schedule"] swp-page-container') as HTMLElement; this.drawer?.classList.add('open');
if (container) { document.body.classList.add('schedule-drawer-open');
const currentStyles = getComputedStyle(container);
const currentMaxWidth = currentStyles.maxWidth;
const currentMargin = currentStyles.margin;
const currentPaddingRight = currentStyles.paddingRight;
// Animate kun padding fra gemt værdi
if (container) {
container.animate([ container.animate([
{ maxWidth: currentMaxWidth, margin: currentMargin, paddingRight: currentPaddingRight }, { paddingRight: startPadding },
{ maxWidth: 'none', margin: '0px', paddingRight: '420px' } { paddingRight: '420px' }
], { ], {
duration: 300, duration: 200,
easing: 'ease', easing: 'ease',
fill: 'forwards' fill: 'forwards'
}); });
} }
this.drawer?.classList.add('open');
document.body.classList.add('schedule-drawer-open');
} }
/** /**
* Close the schedule drawer * Close the schedule drawer
*/ */
private closeDrawer(): void { private closeDrawer(): void {
// Luk drawer med det samme (visuelt)
this.drawer?.classList.remove('open');
const container = document.querySelector('swp-tab-content[data-tab="schedule"] swp-page-container') as HTMLElement; const container = document.querySelector('swp-tab-content[data-tab="schedule"] swp-page-container') as HTMLElement;
const table = document.getElementById('scheduleTable');
if (container) { if (container) {
// Hent nuværende computed styles for animation
const animation = container.getAnimations()[0]; const animation = container.getAnimations()[0];
if (animation) { if (animation) {
// Afspil animationen baglæns
animation.reverse();
animation.onfinish = () => {
animation.cancel(); animation.cancel();
} // Fjern klasser og låst bredde når animation er færdig
} document.body.classList.remove('schedule-drawer-open');
// Fjern låst bredde så tabellen kan tilpasse sig igen
const table = document.getElementById('scheduleTable');
if (table) { if (table) {
table.style.width = ''; table.style.width = '';
} }
};
return; // Exit early - cleanup happens in onfinish
}
}
this.drawer?.classList.remove('open'); // Ingen animation, fjern klasser og låst bredde med det samme
document.body.classList.remove('schedule-drawer-open'); document.body.classList.remove('schedule-drawer-open');
if (table) {
table.style.width = '';
}
} }
} }

View file

@ -13,10 +13,12 @@ export class ThemeController {
private root: HTMLElement; private root: HTMLElement;
private themeOptions: NodeListOf<HTMLElement>; private themeOptions: NodeListOf<HTMLElement>;
private themeCheckbox: HTMLInputElement | null;
constructor() { constructor() {
this.root = document.documentElement; this.root = document.documentElement;
this.themeOptions = document.querySelectorAll<HTMLElement>('swp-theme-option'); this.themeOptions = document.querySelectorAll<HTMLElement>('swp-theme-option');
this.themeCheckbox = document.getElementById('themeCheckbox') as HTMLInputElement | null;
this.applyTheme(this.current); this.applyTheme(this.current);
this.updateUI(); this.updateUI();
@ -77,15 +79,19 @@ export class ThemeController {
} }
private updateUI(): void { private updateUI(): void {
if (!this.themeOptions) return;
const darkActive = this.isDark; const darkActive = this.isDark;
this.themeOptions.forEach(option => { // Update theme options
this.themeOptions?.forEach(option => {
const theme = option.dataset.theme as Theme; const theme = option.dataset.theme as Theme;
const isActive = (theme === 'dark' && darkActive) || (theme === 'light' && !darkActive); const isActive = (theme === 'dark' && darkActive) || (theme === 'light' && !darkActive);
option.classList.toggle('active', isActive); option.classList.toggle('active', isActive);
}); });
// Update checkbox (checked = dark mode)
if (this.themeCheckbox) {
this.themeCheckbox.checked = darkActive;
}
} }
private setupListeners(): void { private setupListeners(): void {
@ -94,6 +100,11 @@ export class ThemeController {
option.addEventListener('click', (e) => this.handleOptionClick(e)); option.addEventListener('click', (e) => this.handleOptionClick(e));
}); });
// Theme checkbox toggle
this.themeCheckbox?.addEventListener('change', () => {
this.set(this.themeCheckbox!.checked ? 'dark' : 'light');
});
// System theme changes // System theme changes
window.matchMedia('(prefers-color-scheme: dark)') window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', () => this.handleSystemChange()); .addEventListener('change', () => this.handleSystemChange());