diff --git a/.workbench/drawer-worktime.png b/.workbench/drawer-worktime.png
new file mode 100644
index 0000000..bf1df84
Binary files /dev/null and b/.workbench/drawer-worktime.png differ
diff --git a/.workbench/image.png b/.workbench/invoice.png
similarity index 100%
rename from .workbench/image.png
rename to .workbench/invoice.png
diff --git a/PlanTempus.Application/Features/Employees/Components/EmployeeWorkSchedule/Default.cshtml b/PlanTempus.Application/Features/Employees/Components/EmployeeWorkSchedule/Default.cshtml
new file mode 100644
index 0000000..aa04b71
--- /dev/null
+++ b/PlanTempus.Application/Features/Employees/Components/EmployeeWorkSchedule/Default.cshtml
@@ -0,0 +1,179 @@
+@model PlanTempus.Application.Features.Employees.Components.EmployeeWorkScheduleViewModel
+
+@{
+ string GetTimeDisplay(ShiftData? shift)
+ {
+ if (shift == null) return "—";
+ return shift.Status switch
+ {
+ "work" => $"{shift.Start} - {shift.End}",
+ "vacation" => Model.LabelVacation,
+ "sick" => Model.LabelSick,
+ _ => "—"
+ };
+ }
+
+ string GetTimeClass(ShiftData? shift)
+ {
+ if (shift == null) return "off";
+ return shift.Status switch
+ {
+ "work" => "",
+ "vacation" => "vacation",
+ "sick" => "sick",
+ _ => "off"
+ };
+ }
+
+ bool IsClosed(string date) => Model.ClosedDays.Contains(date);
+}
+
+
+
+
+
+
+ @Model.ButtonEdit
+
+
+
+
+
+
+
+ @foreach (var day in Model.Days)
+ {
+
+ }
+
+
+ @foreach (var employee in Model.Employees)
+ {
+
+ @employee.Name
+ @employee.WeeklyHours @Model.LabelHours
+
+
+ @foreach (var day in Model.Days)
+ {
+ var shift = employee.Schedule.GetValueOrDefault(day.Date);
+ var isClosed = IsClosed(day.Date);
+
+ @GetTimeDisplay(shift)
+
+ }
+ }
+
+
+
+
+
+
+ @Model.LabelEditShift
+
+
+
+
+
+
+
+
+ Medarbejder
+
+ ?
+ Vælg celle...
+
+
+
+
+ Dato
+ —
+
+
+
+
+
+
+ @Model.LabelStatus
+
+ @Model.LabelWork
+ @Model.LabelOff
+ @Model.LabelVacation
+ @Model.LabelSick
+
+
+
+
+
+ @Model.LabelTimeRange
+
+
+
+
+
+
+
+
+ 09:00 – 17:00
+ 8 timer
+
+
+
+
+
+
+ @Model.LabelNote (valgfrit)
+
+
+
+
+
+
+
+ @Model.LabelType
+
+ @Model.LabelSingle
+ @Model.LabelRepeat
+
+
+
+
+
+
+ @Model.LabelRepeatInterval
+
+
+
+
+
+ Gentagelser bruger valgt dato som startuge.
+
+
+ @Model.LabelRepeatEnd (valgfrit)
+
+
+
+
+ @Model.LabelWeekday (auto)
+ —
+
+
+
+
+
+ @Model.LabelCancel
+ @Model.LabelSave
+
+
diff --git a/PlanTempus.Application/Features/Employees/Components/EmployeeWorkSchedule/EmployeeWorkScheduleViewComponent.cs b/PlanTempus.Application/Features/Employees/Components/EmployeeWorkSchedule/EmployeeWorkScheduleViewComponent.cs
new file mode 100644
index 0000000..f6ea737
--- /dev/null
+++ b/PlanTempus.Application/Features/Employees/Components/EmployeeWorkSchedule/EmployeeWorkScheduleViewComponent.cs
@@ -0,0 +1,158 @@
+using System.Text.Json;
+using Microsoft.AspNetCore.Mvc;
+using PlanTempus.Application.Features.Localization.Services;
+
+namespace PlanTempus.Application.Features.Employees.Components;
+
+public class EmployeeWorkScheduleViewComponent : ViewComponent
+{
+ private readonly ILocalizationService _localization;
+ private readonly IWebHostEnvironment _environment;
+
+ public EmployeeWorkScheduleViewComponent(ILocalizationService localization, IWebHostEnvironment environment)
+ {
+ _localization = localization;
+ _environment = environment;
+ }
+
+ public IViewComponentResult Invoke(string key)
+ {
+ var weekSchedule = LoadMockData();
+ var days = GenerateDays(weekSchedule.StartDate);
+
+ var model = new EmployeeWorkScheduleViewModel
+ {
+ WeekNumber = weekSchedule.WeekNumber,
+ Year = weekSchedule.Year,
+ StartDate = weekSchedule.StartDate,
+ EndDate = weekSchedule.EndDate,
+ ClosedDays = weekSchedule.ClosedDays,
+ Employees = weekSchedule.Employees,
+ Days = days,
+
+ // Labels
+ LabelWeek = _localization.Get("employees.detail.schedule.week"),
+ LabelHours = _localization.Get("employees.detail.schedule.hours"),
+ LabelEditShift = _localization.Get("employees.detail.schedule.editShift"),
+ LabelStatus = _localization.Get("employees.detail.schedule.status"),
+ LabelWork = _localization.Get("employees.detail.schedule.work"),
+ LabelOff = _localization.Get("employees.detail.schedule.off"),
+ LabelVacation = _localization.Get("employees.detail.schedule.vacation"),
+ LabelSick = _localization.Get("employees.detail.schedule.sick"),
+ LabelTimeRange = _localization.Get("employees.detail.schedule.timeRange"),
+ LabelNote = _localization.Get("employees.detail.schedule.note"),
+ LabelType = _localization.Get("employees.detail.schedule.type"),
+ LabelSingle = _localization.Get("employees.detail.schedule.single"),
+ LabelRepeat = _localization.Get("employees.detail.schedule.repeat"),
+ LabelRepeatInterval = _localization.Get("employees.detail.schedule.repeatInterval"),
+ LabelRepeatEnd = _localization.Get("employees.detail.schedule.repeatEnd"),
+ LabelWeekday = _localization.Get("employees.detail.schedule.weekday"),
+ LabelEdit = _localization.Get("common.edit"),
+ LabelSave = _localization.Get("common.save"),
+ LabelCancel = _localization.Get("common.cancel"),
+ ButtonEdit = _localization.Get("employees.detail.schedule.buttonEdit"),
+ ButtonDone = _localization.Get("employees.detail.schedule.buttonDone")
+ };
+
+ 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(json, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ })!;
+ }
+
+ private List GenerateDays(string startDate)
+ {
+ var start = DateTime.Parse(startDate);
+ var dayNames = new[] { "Søndag", "Mandag", "Tirsdag", "Onsdag", "Torsdag", "Fredag", "Lørdag" };
+ var shortDayNames = new[] { "Søn", "Man", "Tir", "Ons", "Tor", "Fre", "Lør" };
+
+ var days = new List();
+ for (int i = 0; i < 7; i++)
+ {
+ var date = start.AddDays(i);
+ days.Add(new DayInfo
+ {
+ Date = date.ToString("yyyy-MM-dd"),
+ DayName = dayNames[(int)date.DayOfWeek],
+ ShortDayName = shortDayNames[(int)date.DayOfWeek],
+ DisplayDate = date.ToString("dd/MM")
+ });
+ }
+ return days;
+ }
+}
+
+public class EmployeeWorkScheduleViewModel
+{
+ public int WeekNumber { get; init; }
+ public int Year { get; init; }
+ public required string StartDate { get; init; }
+ public required string EndDate { get; init; }
+ public required List ClosedDays { get; init; }
+ public required List Employees { get; init; }
+ public required List Days { get; init; }
+
+ // Labels
+ public required string LabelWeek { get; init; }
+ public required string LabelHours { get; init; }
+ public required string LabelEditShift { get; init; }
+ public required string LabelStatus { get; init; }
+ public required string LabelWork { get; init; }
+ public required string LabelOff { get; init; }
+ public required string LabelVacation { get; init; }
+ public required string LabelSick { get; init; }
+ public required string LabelTimeRange { get; init; }
+ public required string LabelNote { get; init; }
+ public required string LabelType { get; init; }
+ public required string LabelSingle { get; init; }
+ public required string LabelRepeat { get; init; }
+ public required string LabelRepeatInterval { get; init; }
+ public required string LabelRepeatEnd { get; init; }
+ public required string LabelWeekday { get; init; }
+ public required string LabelEdit { get; init; }
+ public required string LabelSave { get; init; }
+ public required string LabelCancel { get; init; }
+ public required string ButtonEdit { get; init; }
+ public required string ButtonDone { get; init; }
+}
+
+public class DayInfo
+{
+ public required string Date { get; init; }
+ public required string DayName { get; init; }
+ public required string ShortDayName { get; init; }
+ public required string DisplayDate { get; init; }
+}
+
+public class WeekScheduleData
+{
+ public int WeekNumber { get; init; }
+ public int Year { get; init; }
+ public required string StartDate { get; init; }
+ public required string EndDate { get; init; }
+ public required List ClosedDays { get; init; }
+ public required List Employees { get; init; }
+}
+
+public class EmployeeScheduleData
+{
+ public required string EmployeeId { get; init; }
+ public required string Name { get; init; }
+ public int WeeklyHours { get; init; }
+ public required Dictionary Schedule { get; init; }
+}
+
+public class ShiftData
+{
+ public required string Status { get; init; }
+ public string? Start { get; init; }
+ public string? End { get; init; }
+ public string? Note { get; init; }
+}
diff --git a/PlanTempus.Application/Features/Employees/Data/workScheduleMock.json b/PlanTempus.Application/Features/Employees/Data/workScheduleMock.json
new file mode 100644
index 0000000..9995b5e
--- /dev/null
+++ b/PlanTempus.Application/Features/Employees/Data/workScheduleMock.json
@@ -0,0 +1,79 @@
+{
+ "weekNumber": 52,
+ "year": 2025,
+ "startDate": "2025-12-23",
+ "endDate": "2025-12-29",
+ "closedDays": ["2025-12-25"],
+ "employees": [
+ {
+ "employeeId": "emp-1",
+ "name": "Anna Sørensen",
+ "weeklyHours": 32,
+ "schedule": {
+ "2025-12-23": { "status": "work", "start": "09:00", "end": "17:00" },
+ "2025-12-24": { "status": "work", "start": "09:00", "end": "13:00" },
+ "2025-12-25": { "status": "off" },
+ "2025-12-26": { "status": "off" },
+ "2025-12-27": { "status": "work", "start": "09:00", "end": "17:00" },
+ "2025-12-28": { "status": "work", "start": "10:00", "end": "14:00" },
+ "2025-12-29": { "status": "off" }
+ }
+ },
+ {
+ "employeeId": "emp-2",
+ "name": "Mette Jensen",
+ "weeklyHours": 40,
+ "schedule": {
+ "2025-12-23": { "status": "work", "start": "10:00", "end": "18:00" },
+ "2025-12-24": { "status": "work", "start": "10:00", "end": "18:00" },
+ "2025-12-25": { "status": "vacation" },
+ "2025-12-26": { "status": "vacation" },
+ "2025-12-27": { "status": "vacation" },
+ "2025-12-28": { "status": "off" },
+ "2025-12-29": { "status": "off" }
+ }
+ },
+ {
+ "employeeId": "emp-3",
+ "name": "Louise Nielsen",
+ "weeklyHours": 37,
+ "schedule": {
+ "2025-12-23": { "status": "work", "start": "09:00", "end": "17:00" },
+ "2025-12-24": { "status": "work", "start": "09:00", "end": "17:00" },
+ "2025-12-25": { "status": "off" },
+ "2025-12-26": { "status": "off" },
+ "2025-12-27": { "status": "work", "start": "09:00", "end": "17:00" },
+ "2025-12-28": { "status": "work", "start": "09:00", "end": "14:00" },
+ "2025-12-29": { "status": "off" }
+ }
+ },
+ {
+ "employeeId": "emp-4",
+ "name": "Katrine Pedersen",
+ "weeklyHours": 24,
+ "schedule": {
+ "2025-12-23": { "status": "work", "start": "12:00", "end": "20:00" },
+ "2025-12-24": { "status": "off" },
+ "2025-12-25": { "status": "off" },
+ "2025-12-26": { "status": "off" },
+ "2025-12-27": { "status": "work", "start": "12:00", "end": "20:00" },
+ "2025-12-28": { "status": "work", "start": "10:00", "end": "18:00" },
+ "2025-12-29": { "status": "off" }
+ }
+ },
+ {
+ "employeeId": "emp-5",
+ "name": "Sofie Andersen",
+ "weeklyHours": 20,
+ "schedule": {
+ "2025-12-23": { "status": "sick" },
+ "2025-12-24": { "status": "work", "start": "09:00", "end": "15:00" },
+ "2025-12-25": { "status": "off" },
+ "2025-12-26": { "status": "off" },
+ "2025-12-27": { "status": "work", "start": "09:00", "end": "15:00" },
+ "2025-12-28": { "status": "off" },
+ "2025-12-29": { "status": "off" }
+ }
+ }
+ ]
+}
diff --git a/PlanTempus.Application/Features/Employees/Pages/Index.cshtml b/PlanTempus.Application/Features/Employees/Pages/Index.cshtml
index 5481d20..45db507 100644
--- a/PlanTempus.Application/Features/Employees/Pages/Index.cshtml
+++ b/PlanTempus.Application/Features/Employees/Pages/Index.cshtml
@@ -35,6 +35,10 @@
Roller
+
+
+ Vagtplan
+
@@ -51,6 +55,13 @@
@await Component.InvokeAsync("PermissionsMatrix", "default")
+
+
+
+
+ @await Component.InvokeAsync("EmployeeWorkSchedule", "default")
+
+
diff --git a/PlanTempus.Application/Features/Localization/Translations/da.json b/PlanTempus.Application/Features/Localization/Translations/da.json
index 8c294f4..1cdf225 100644
--- a/PlanTempus.Application/Features/Localization/Translations/da.json
+++ b/PlanTempus.Application/Features/Localization/Translations/da.json
@@ -271,11 +271,32 @@
"tabs": {
"general": "Generelt",
"hours": "Arbejdstid",
+ "schedule": "Vagtplan",
"services": "Services",
"salary": "Løn",
"hr": "HR",
"stats": "Statistik"
},
+ "schedule": {
+ "week": "Uge",
+ "hours": "timer",
+ "editShift": "Redigér vagt",
+ "status": "Status",
+ "work": "Arbejde",
+ "off": "Fri",
+ "vacation": "Ferie",
+ "sick": "Syg",
+ "timeRange": "Tidsrum",
+ "note": "Note",
+ "type": "Type",
+ "single": "Enkelt",
+ "repeat": "Gentagelse",
+ "repeatInterval": "Gentag",
+ "repeatEnd": "Slutdato",
+ "weekday": "Ugedag",
+ "buttonEdit": "Rediger",
+ "buttonDone": "Færdig"
+ },
"contact": "Kontaktoplysninger",
"personal": "Personlige oplysninger",
"employment": "Ansættelse",
diff --git a/PlanTempus.Application/Features/Localization/Translations/en.json b/PlanTempus.Application/Features/Localization/Translations/en.json
index 796b6ca..5dc90f0 100644
--- a/PlanTempus.Application/Features/Localization/Translations/en.json
+++ b/PlanTempus.Application/Features/Localization/Translations/en.json
@@ -271,11 +271,32 @@
"tabs": {
"general": "General",
"hours": "Working hours",
+ "schedule": "Schedule",
"services": "Services",
"salary": "Salary",
"hr": "HR",
"stats": "Statistics"
},
+ "schedule": {
+ "week": "Week",
+ "hours": "hours",
+ "editShift": "Edit shift",
+ "status": "Status",
+ "work": "Work",
+ "off": "Off",
+ "vacation": "Vacation",
+ "sick": "Sick",
+ "timeRange": "Time range",
+ "note": "Note",
+ "type": "Type",
+ "single": "Single",
+ "repeat": "Repeat",
+ "repeatInterval": "Repeat",
+ "repeatEnd": "End date",
+ "weekday": "Weekday",
+ "buttonEdit": "Edit",
+ "buttonDone": "Done"
+ },
"contact": "Contact information",
"personal": "Personal information",
"employment": "Employment",
diff --git a/PlanTempus.Application/wwwroot/css/employees.css b/PlanTempus.Application/wwwroot/css/employees.css
index fd49f6b..46c5202 100644
--- a/PlanTempus.Application/wwwroot/css/employees.css
+++ b/PlanTempus.Application/wwwroot/css/employees.css
@@ -879,6 +879,531 @@ swp-data-row.focus-highlight {
}
}
+/* ===========================================
+ WORK SCHEDULE TABLE
+ =========================================== */
+swp-schedule-table {
+ display: grid;
+ grid-template-columns: 180px repeat(7, minmax(100px, 1fr));
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ border: 1px solid var(--color-border);
+ background: var(--color-surface);
+}
+
+swp-schedule-cell {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ padding: 12px 16px;
+ min-height: 60px;
+ background: var(--color-surface);
+ border-right: 1px solid var(--color-border);
+ border-bottom: 1px solid var(--color-border);
+ user-select: none;
+}
+
+/* Last column: no right border */
+swp-schedule-cell:nth-child(8n) {
+ border-right: none;
+}
+
+/* Last row: no bottom border */
+swp-schedule-cell:nth-last-child(-n+8) {
+ border-bottom: none;
+}
+
+swp-schedule-cell.header {
+ background: var(--color-background-alt);
+ font-weight: var(--font-weight-medium);
+ font-size: 13px;
+ color: var(--color-text-secondary);
+ min-height: 48px;
+ text-align: center;
+ align-items: center;
+}
+
+swp-schedule-cell.header.week-number {
+ font-size: 15px;
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text);
+}
+
+swp-schedule-cell.header.closed {
+ background: color-mix(in srgb, #f59e0b 10%, var(--color-background-alt));
+ border-top: 2px solid #f59e0b;
+ border-left: 2px solid #f59e0b;
+ border-right: 2px solid #f59e0b;
+ border-bottom: none;
+
+ swp-day-name {
+ color: #d97706;
+ }
+}
+
+swp-schedule-cell.employee {
+ align-items: flex-start;
+ gap: 2px;
+}
+
+swp-schedule-cell.day {
+ align-items: center;
+ text-align: center;
+ position: relative;
+}
+
+swp-schedule-cell.day.closed-day {
+ background: color-mix(in srgb, #f59e0b 6%, var(--color-surface));
+ border-left: 2px solid #f59e0b;
+ border-right: 2px solid #f59e0b;
+
+ swp-time-display {
+ opacity: 0.5;
+ }
+}
+
+/* Last cell in closed column gets bottom border */
+swp-schedule-cell.day.closed-day:nth-last-child(-n+8) {
+ border-bottom: 2px solid #f59e0b;
+}
+
+/* Schedule employee info */
+swp-schedule-cell swp-employee-name {
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text);
+}
+
+swp-schedule-cell swp-employee-hours {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--color-text-muted);
+}
+
+/* Day header */
+swp-day-name {
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text);
+}
+
+swp-day-date {
+ font-size: 12px;
+ color: var(--color-text-muted);
+ font-weight: var(--font-weight-normal);
+}
+
+/* Time display variants */
+swp-time-display {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ font-weight: var(--font-weight-medium);
+ padding: 4px 8px;
+ border-radius: 4px;
+ background: var(--bg-teal-light);
+ color: var(--color-text);
+ white-space: nowrap;
+ min-width: 90px;
+ text-align: center;
+ display: inline-block;
+}
+
+swp-time-display.off {
+ background: transparent;
+ color: var(--color-text-muted);
+}
+
+swp-time-display.off.off-override {
+ background: color-mix(in srgb, #7c3aed 12%, white);
+ color: #6d28d9;
+}
+
+swp-time-display.vacation {
+ background: color-mix(in srgb, #f59e0b 15%, white);
+ color: #b45309;
+}
+
+swp-time-display.sick {
+ background: color-mix(in srgb, #ef4444 15%, white);
+ color: #dc2626;
+}
+
+/* Edit mode */
+body.schedule-edit-mode swp-schedule-cell.day {
+ cursor: pointer;
+}
+
+body.schedule-edit-mode swp-schedule-cell.day:hover {
+ background: var(--color-background-alt);
+}
+
+body.schedule-edit-mode swp-schedule-cell.day.selected {
+ background: color-mix(in srgb, var(--color-teal) 12%, white);
+ border: 2px solid var(--color-teal);
+ margin: -1px;
+ padding: 11px 15px;
+}
+
+body.schedule-edit-mode swp-schedule-cell.header:not(.week-number) {
+ cursor: pointer;
+}
+
+body.schedule-edit-mode swp-schedule-cell.header:not(.week-number):hover {
+ background: var(--color-border);
+}
+
+/* Status options in drawer */
+swp-status-options {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+swp-status-option {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ font-size: 13px;
+ font-weight: var(--font-weight-medium);
+ background: var(--color-background-alt);
+ color: var(--color-text-secondary);
+
+ &::before {
+ content: '';
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ }
+
+ &[data-status="work"] {
+ --status-color: var(--color-teal);
+ }
+ &[data-status="off"] {
+ --status-color: #7c3aed;
+ }
+ &[data-status="vacation"] {
+ --status-color: #f59e0b;
+ }
+ &[data-status="sick"] {
+ --status-color: #e53935;
+ }
+
+ &::before {
+ background: var(--status-color);
+ }
+
+ &:hover {
+ background: var(--color-border);
+ }
+
+ &.selected {
+ background: color-mix(in srgb, var(--status-color) 15%, white);
+ color: var(--status-color);
+ }
+}
+
+/* Time range slider */
+swp-time-range {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+swp-time-range-slider {
+ position: relative;
+ flex: 1;
+ height: 20px;
+ display: flex;
+ align-items: center;
+}
+
+swp-time-range-track {
+ position: absolute;
+ width: 100%;
+ height: 4px;
+ background: var(--color-border);
+ border-radius: 2px;
+}
+
+swp-time-range-fill {
+ position: absolute;
+ height: 4px;
+ background: var(--color-teal);
+ border-radius: 2px;
+ cursor: grab;
+
+ &:active {
+ cursor: grabbing;
+ }
+}
+
+swp-time-range-slider input[type="range"] {
+ position: absolute;
+ width: 100%;
+ height: 4px;
+ -webkit-appearance: none;
+ appearance: none;
+ background: transparent;
+ pointer-events: none;
+ margin: 0;
+
+ &::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 14px;
+ height: 14px;
+ background: var(--color-teal);
+ border: 2px solid white;
+ border-radius: 50%;
+ cursor: pointer;
+ pointer-events: auto;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2);
+ }
+
+ &::-moz-range-thumb {
+ width: 14px;
+ height: 14px;
+ background: var(--color-teal);
+ border: 2px solid white;
+ border-radius: 50%;
+ cursor: pointer;
+ pointer-events: auto;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2);
+ }
+}
+
+swp-time-range-label {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ min-width: 100px;
+ text-align: center;
+ background: var(--color-background-alt);
+ padding: 6px 12px;
+ border-radius: 4px;
+}
+
+swp-time-range-times {
+ font-size: 13px;
+ font-family: var(--font-mono);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text);
+ white-space: nowrap;
+}
+
+swp-time-range-duration {
+ font-size: 11px;
+ font-family: var(--font-mono);
+ color: var(--color-text-secondary);
+ 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 */
+swp-employee-display {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+
+ swp-employee-avatar {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, var(--color-teal) 0%, #00695c 100%);
+ color: white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: var(--font-weight-semibold);
+ font-size: 12px;
+ flex-shrink: 0;
+ }
+
+ &.empty swp-employee-avatar {
+ background: var(--color-border);
+ color: var(--color-text-muted);
+ }
+
+ &.multi swp-employee-avatar {
+ background: var(--color-text-muted);
+ }
+}
+
+/* ===========================================
+ SCHEDULE DRAWER (matches POC exactly)
+ =========================================== */
+
+/* Drawer header with background */
+#schedule-drawer swp-drawer-header {
+ background: var(--color-background-alt);
+ padding: 20px 24px;
+}
+
+#schedule-drawer swp-drawer-title {
+ font-size: 18px;
+}
+
+/* Drawer body/content */
+#schedule-drawer swp-drawer-body {
+ padding: 24px;
+}
+
+/* Form row layout */
+#schedule-drawer swp-form-row {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-bottom: 16px;
+}
+
+/* Form labels - uppercase style from POC */
+#schedule-drawer swp-form-label {
+ font-size: 11px;
+ font-weight: 400;
+ color: var(--color-text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+
+ .optional,
+ .auto {
+ font-weight: 400;
+ text-transform: none;
+ color: var(--color-text-muted);
+ }
+}
+
+/* Form value (read-only display) */
+#schedule-drawer swp-form-value {
+ font-size: 15px;
+ font-weight: 500;
+ color: var(--color-text);
+}
+
+/* Form divider */
+#schedule-drawer swp-form-divider {
+ display: block;
+ height: 1px;
+ background: var(--color-border);
+ margin: 20px 0;
+}
+
+/* Form hint text */
+#schedule-drawer swp-form-hint {
+ display: block;
+ font-size: 12px;
+ color: var(--color-text-muted);
+ margin: -8px 0 16px 0;
+ line-height: 1.4;
+}
+
+/* Form group - gray card background from POC */
+#schedule-drawer swp-form-group {
+ display: block;
+ padding: 16px;
+ background: var(--color-background-alt);
+ border-radius: 8px;
+ margin-top: 16px;
+
+ swp-form-row:last-child {
+ margin-bottom: 0;
+ }
+}
+
+/* Form select wrapper */
+#schedule-drawer swp-form-select {
+ display: block;
+
+ select {
+ width: 100%;
+ padding: 10px 12px;
+ border: 1px solid var(--color-border);
+ border-radius: 6px;
+ font-size: 14px;
+ font-family: var(--font-family);
+ color: var(--color-text);
+ background: var(--color-surface);
+ cursor: pointer;
+
+ &:focus {
+ outline: none;
+ border-color: var(--color-teal);
+ }
+ }
+}
+
+/* Text inputs in drawer */
+#schedule-drawer input[type="text"],
+#schedule-drawer input[type="date"] {
+ width: 100%;
+ padding: 10px 12px;
+ border: 1px solid var(--color-border);
+ border-radius: 6px;
+ font-size: 14px;
+ font-family: var(--font-family);
+ color: var(--color-text);
+ background: var(--color-surface);
+
+ &::placeholder {
+ color: var(--color-text-muted);
+ }
+
+ &:focus {
+ outline: none;
+ border-color: var(--color-teal);
+ }
+}
+
+/* Drawer footer with background */
+#schedule-drawer swp-drawer-footer {
+ display: flex;
+ gap: 12px;
+ padding: 20px 24px;
+ border-top: 1px solid var(--color-border);
+ background: var(--color-background-alt);
+
+ swp-btn {
+ flex: 1;
+ }
+}
+
/* ===========================================
RESPONSIVE
=========================================== */
diff --git a/PlanTempus.Application/wwwroot/ts/modules/employees.ts b/PlanTempus.Application/wwwroot/ts/modules/employees.ts
index 7ee10c6..f763390 100644
--- a/PlanTempus.Application/wwwroot/ts/modules/employees.ts
+++ b/PlanTempus.Application/wwwroot/ts/modules/employees.ts
@@ -8,6 +8,7 @@
export class EmployeesController {
private ratesSync: RatesSyncController | null = null;
+ private scheduleController: ScheduleController | null = null;
private listView: HTMLElement | null = null;
private detailView: HTMLElement | null = null;
@@ -25,6 +26,7 @@ export class EmployeesController {
this.setupHistoryNavigation();
this.restoreStateFromUrl();
this.ratesSync = new RatesSyncController();
+ this.scheduleController = new ScheduleController();
}
/**
@@ -397,3 +399,646 @@ class RatesSyncController {
});
}
}
+
+/**
+ * Schedule Controller
+ *
+ * Handles work schedule (vagtplan) functionality:
+ * - Edit mode toggle
+ * - Cell selection (single, ctrl+click, shift+click)
+ * - Drawer interaction
+ * - Time range slider
+ * - Status options
+ */
+class ScheduleController {
+ private isEditMode = false;
+ private selectedCells: HTMLElement[] = [];
+ private anchorCell: HTMLElement | null = null;
+ private drawer: HTMLElement | null = null;
+ private editBtn: HTMLElement | null = null;
+ private scheduleTable: HTMLElement | null = null;
+
+ // Time range constants
+ private readonly TIME_RANGE_MAX = 60; // 15 hours (06:00-21:00) * 4 intervals
+
+ constructor() {
+ this.drawer = document.getElementById('schedule-drawer');
+ this.editBtn = document.getElementById('scheduleEditBtn');
+ this.scheduleTable = document.getElementById('scheduleTable');
+
+ if (!this.scheduleTable) return;
+
+ this.setupEditModeToggle();
+ this.setupCellSelection();
+ this.setupStatusOptions();
+ this.setupTypeToggle();
+ this.setupTimeRangeSlider();
+ this.setupDrawerSave();
+ }
+
+ /**
+ * Setup edit mode toggle button
+ */
+ private setupEditModeToggle(): void {
+ this.editBtn?.addEventListener('click', () => {
+ this.isEditMode = !this.isEditMode;
+ document.body.classList.toggle('schedule-edit-mode', this.isEditMode);
+
+ if (this.editBtn) {
+ const icon = this.editBtn.querySelector('i');
+ const text = this.editBtn.childNodes[this.editBtn.childNodes.length - 1];
+
+ if (this.isEditMode) {
+ icon?.classList.replace('ph-pencil-simple', 'ph-check');
+ if (text && text.nodeType === Node.TEXT_NODE) {
+ text.textContent = ' Færdig';
+ }
+ this.openDrawer();
+ this.showEmptyState();
+ } else {
+ icon?.classList.replace('ph-check', 'ph-pencil-simple');
+ if (text && text.nodeType === Node.TEXT_NODE) {
+ text.textContent = ' Rediger';
+ }
+ this.closeDrawer();
+ this.clearSelection();
+ }
+ }
+ });
+ }
+
+ /**
+ * Setup cell click selection
+ */
+ private setupCellSelection(): void {
+ if (!this.scheduleTable) return;
+
+ const dayCells = this.scheduleTable.querySelectorAll('swp-schedule-cell.day');
+
+ dayCells.forEach(cell => {
+ // Double-click to enter edit mode
+ cell.addEventListener('dblclick', () => {
+ if (!this.isEditMode) {
+ this.isEditMode = true;
+ document.body.classList.add('schedule-edit-mode');
+
+ if (this.editBtn) {
+ const icon = this.editBtn.querySelector('i');
+ const text = this.editBtn.childNodes[this.editBtn.childNodes.length - 1];
+ icon?.classList.replace('ph-pencil-simple', 'ph-check');
+ if (text && text.nodeType === Node.TEXT_NODE) {
+ text.textContent = ' Færdig';
+ }
+ }
+
+ this.openDrawer();
+ this.clearSelection();
+ cell.classList.add('selected');
+ this.selectedCells = [cell];
+ this.anchorCell = cell;
+ this.updateDrawerFields();
+ }
+ });
+
+ // Click selection in edit mode
+ cell.addEventListener('click', (e: MouseEvent) => {
+ if (!this.isEditMode) return;
+
+ if (e.shiftKey && this.anchorCell) {
+ // Shift+click: range selection
+ this.selectRange(this.anchorCell, cell);
+ } else if (e.ctrlKey || e.metaKey) {
+ // Ctrl/Cmd+click: toggle selection
+ if (cell.classList.contains('selected')) {
+ cell.classList.remove('selected');
+ this.selectedCells = this.selectedCells.filter(c => c !== cell);
+ } else {
+ cell.classList.add('selected');
+ this.selectedCells.push(cell);
+ this.anchorCell = cell;
+ }
+ } else {
+ // Single click: replace selection
+ this.clearSelection();
+ cell.classList.add('selected');
+ this.selectedCells = [cell];
+ this.anchorCell = cell;
+ }
+
+ this.updateDrawerFields();
+ });
+ });
+ }
+
+ /**
+ * Select a range of cells between anchor and target
+ */
+ private selectRange(anchor: HTMLElement, target: HTMLElement): void {
+ if (!this.scheduleTable) return;
+
+ const allDayCells = Array.from(this.scheduleTable.querySelectorAll('swp-schedule-cell.day'));
+ const anchorIdx = allDayCells.indexOf(anchor);
+ const targetIdx = allDayCells.indexOf(target);
+
+ // Calculate grid positions (7 columns for days)
+ const anchorRow = Math.floor(anchorIdx / 7);
+ const anchorCol = anchorIdx % 7;
+ const targetRow = Math.floor(targetIdx / 7);
+ const targetCol = targetIdx % 7;
+
+ const minRow = Math.min(anchorRow, targetRow);
+ const maxRow = Math.max(anchorRow, targetRow);
+ const minCol = Math.min(anchorCol, targetCol);
+ const maxCol = Math.max(anchorCol, targetCol);
+
+ this.clearSelection();
+
+ allDayCells.forEach((c, idx) => {
+ const row = Math.floor(idx / 7);
+ const col = idx % 7;
+ if (row >= minRow && row <= maxRow && col >= minCol && col <= maxCol) {
+ c.classList.add('selected');
+ this.selectedCells.push(c);
+ }
+ });
+ }
+
+ /**
+ * Clear all selected cells
+ */
+ private clearSelection(): void {
+ this.selectedCells.forEach(c => c.classList.remove('selected'));
+ this.selectedCells = [];
+ }
+
+ /**
+ * Get initials from name
+ */
+ private getInitials(name: string): string {
+ return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
+ }
+
+ /**
+ * Show empty state in drawer
+ */
+ private showEmptyState(): void {
+ const employeeDisplay = document.getElementById('scheduleFieldEmployee');
+ const avatar = document.getElementById('scheduleFieldAvatar');
+ const employeeName = document.getElementById('scheduleFieldEmployeeName');
+ const dateField = document.getElementById('scheduleFieldDate');
+ const weekdayField = document.getElementById('scheduleFieldWeekday');
+ const drawerBody = this.drawer?.querySelector('swp-drawer-body') as HTMLElement;
+ const drawerFooter = this.drawer?.querySelector('swp-drawer-footer') as HTMLElement;
+
+ if (employeeDisplay) employeeDisplay.classList.add('empty');
+ if (employeeDisplay) employeeDisplay.classList.remove('multi');
+ if (avatar) avatar.textContent = '?';
+ if (employeeName) employeeName.textContent = 'Vælg celle...';
+ if (dateField) dateField.textContent = '—';
+ if (weekdayField) weekdayField.textContent = '—';
+ if (drawerBody) {
+ drawerBody.style.opacity = '0.5';
+ drawerBody.style.pointerEvents = 'none';
+ }
+ if (drawerFooter) drawerFooter.style.display = 'none';
+ }
+
+ /**
+ * Show edit state in drawer
+ */
+ private showEditState(): void {
+ const drawerBody = this.drawer?.querySelector('swp-drawer-body') as HTMLElement;
+ const drawerFooter = this.drawer?.querySelector('swp-drawer-footer') as HTMLElement;
+
+ if (drawerBody) {
+ drawerBody.style.opacity = '1';
+ drawerBody.style.pointerEvents = 'auto';
+ }
+ if (drawerFooter) drawerFooter.style.display = 'flex';
+ }
+
+ /**
+ * Update drawer fields based on selected cells
+ */
+ private updateDrawerFields(): void {
+ if (this.selectedCells.length === 0) {
+ this.showEmptyState();
+ return;
+ }
+
+ this.showEditState();
+
+ const employeeDisplay = document.getElementById('scheduleFieldEmployee');
+ const avatar = document.getElementById('scheduleFieldAvatar');
+ const employeeName = document.getElementById('scheduleFieldEmployeeName');
+ const dateField = document.getElementById('scheduleFieldDate');
+ const weekdayField = document.getElementById('scheduleFieldWeekday');
+
+ employeeDisplay?.classList.remove('empty', 'multi');
+
+ if (this.selectedCells.length === 1) {
+ const cell = this.selectedCells[0];
+ const name = cell.dataset.employee || '';
+ const date = cell.dataset.date || '';
+ const dayName = this.getDayName(cell.dataset.day || '');
+
+ if (employeeName) employeeName.textContent = name;
+ if (avatar) avatar.textContent = this.getInitials(name);
+ if (dateField) dateField.textContent = this.formatDate(date);
+ if (weekdayField) weekdayField.textContent = dayName;
+
+ this.prefillFormFromCell(cell);
+ } else {
+ const employees = [...new Set(this.selectedCells.map(c => c.dataset.employee))];
+ const days = [...new Set(this.selectedCells.map(c => c.dataset.day))];
+
+ if (employees.length === 1) {
+ if (employeeName) employeeName.textContent = employees[0] || '';
+ if (avatar) avatar.textContent = this.getInitials(employees[0] || '');
+ if (dateField) dateField.textContent = `${this.selectedCells.length} dage valgt`;
+ } else {
+ if (employeeName) employeeName.textContent = `${this.selectedCells.length} valgt`;
+ if (avatar) avatar.textContent = String(this.selectedCells.length);
+ employeeDisplay?.classList.add('multi');
+ if (dateField) dateField.textContent = `${employees.length} medarbejdere, ${days.length} dage`;
+ }
+
+ if (days.length === 1) {
+ if (weekdayField) weekdayField.textContent = this.getDayName(days[0] || '');
+ } else {
+ if (weekdayField) weekdayField.textContent = 'Flere dage';
+ }
+
+ this.resetFormToDefault();
+ }
+ }
+
+ /**
+ * Format ISO date to display format
+ */
+ private formatDate(isoDate: string): string {
+ const date = new Date(isoDate);
+ return `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()}`;
+ }
+
+ /**
+ * Get full day name from short name
+ */
+ private getDayName(shortName: string): string {
+ const dayMap: Record = {
+ 'Man': 'Mandag', 'Tir': 'Tirsdag', 'Ons': 'Onsdag',
+ 'Tor': 'Torsdag', 'Fre': 'Fredag', 'Lør': 'Lørdag', 'Søn': 'Søndag'
+ };
+ return dayMap[shortName] || shortName;
+ }
+
+ /**
+ * Prefill form from cell data
+ */
+ private prefillFormFromCell(cell: HTMLElement): void {
+ const timeDisplay = cell.querySelector('swp-time-display');
+ if (!timeDisplay) return;
+
+ let status = 'work';
+ if (timeDisplay.classList.contains('off')) status = 'off';
+ else if (timeDisplay.classList.contains('vacation')) status = 'vacation';
+ else if (timeDisplay.classList.contains('sick')) status = 'sick';
+
+ // Update status options
+ document.querySelectorAll('#scheduleStatusOptions swp-status-option').forEach(opt => {
+ opt.classList.toggle('selected', (opt as HTMLElement).dataset.status === status);
+ });
+
+ // Show/hide time row
+ const timeRow = document.getElementById('scheduleTimeRow');
+ if (timeRow) timeRow.style.display = status === 'work' ? 'flex' : 'none';
+
+ // Parse time if work status
+ if (status === 'work') {
+ const timeText = timeDisplay.textContent?.trim() || '';
+ const timeMatch = timeText.match(/(\d{2}:\d{2})\s*-\s*(\d{2}:\d{2})/);
+ if (timeMatch) {
+ this.setTimeRange(timeMatch[1], timeMatch[2]);
+ }
+ }
+
+ // Reset type to template
+ document.querySelectorAll('#scheduleTypeOptions swp-toggle-option').forEach(opt => {
+ opt.classList.toggle('selected', (opt as HTMLElement).dataset.value === 'template');
+ });
+ const repeatGroup = document.getElementById('scheduleRepeatGroup');
+ if (repeatGroup) repeatGroup.style.display = 'block';
+ }
+
+ /**
+ * Reset form to default values
+ */
+ private resetFormToDefault(): void {
+ document.querySelectorAll('#scheduleStatusOptions swp-status-option').forEach(opt => {
+ opt.classList.toggle('selected', (opt as HTMLElement).dataset.status === 'work');
+ });
+
+ const timeRow = document.getElementById('scheduleTimeRow');
+ if (timeRow) timeRow.style.display = 'flex';
+
+ this.setTimeRange('09:00', '17:00');
+
+ document.querySelectorAll('#scheduleTypeOptions swp-toggle-option').forEach(opt => {
+ opt.classList.toggle('selected', (opt as HTMLElement).dataset.value === 'template');
+ });
+
+ const repeatGroup = document.getElementById('scheduleRepeatGroup');
+ if (repeatGroup) repeatGroup.style.display = 'block';
+
+ const noteField = document.getElementById('scheduleFieldNote') as HTMLInputElement;
+ if (noteField) noteField.value = '';
+ }
+
+ /**
+ * Setup status options click handlers
+ */
+ private setupStatusOptions(): void {
+ document.querySelectorAll('#scheduleStatusOptions swp-status-option').forEach(option => {
+ option.addEventListener('click', () => {
+ document.querySelectorAll('#scheduleStatusOptions swp-status-option').forEach(o =>
+ o.classList.remove('selected')
+ );
+ option.classList.add('selected');
+
+ const status = (option as HTMLElement).dataset.status;
+ const timeRow = document.getElementById('scheduleTimeRow');
+ if (timeRow) timeRow.style.display = status === 'work' ? 'flex' : 'none';
+
+ this.updateSelectedCellsStatus();
+ });
+ });
+ }
+
+ /**
+ * Setup type toggle (single/repeat)
+ */
+ private setupTypeToggle(): void {
+ document.querySelectorAll('#scheduleTypeOptions swp-toggle-option').forEach(option => {
+ option.addEventListener('click', () => {
+ document.querySelectorAll('#scheduleTypeOptions swp-toggle-option').forEach(o =>
+ o.classList.remove('selected')
+ );
+ option.classList.add('selected');
+
+ const isTemplate = (option as HTMLElement).dataset.value === 'template';
+ const repeatGroup = document.getElementById('scheduleRepeatGroup');
+ if (repeatGroup) repeatGroup.style.display = isTemplate ? 'block' : 'none';
+ });
+ });
+ }
+
+ /**
+ * Convert slider value to time string
+ */
+ private valueToTime(value: number): string {
+ const totalMinutes = (value * 15) + (6 * 60);
+ const hours = Math.floor(totalMinutes / 60);
+ const minutes = totalMinutes % 60;
+ return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
+ }
+
+ /**
+ * Convert time string to slider value
+ */
+ private timeToValue(timeStr: string): number {
+ const [hours, minutes] = timeStr.split(':').map(Number);
+ const totalMinutes = hours * 60 + minutes;
+ return Math.round((totalMinutes - 6 * 60) / 15);
+ }
+
+ /**
+ * Set time range slider values
+ */
+ private setTimeRange(startTime: string, endTime: string): void {
+ const timeRange = document.getElementById('scheduleTimeRange');
+ if (!timeRange) return;
+
+ const startInput = timeRange.querySelector('.range-start');
+ const endInput = timeRange.querySelector('.range-end');
+
+ if (startInput) startInput.value = String(this.timeToValue(startTime));
+ if (endInput) endInput.value = String(this.timeToValue(endTime));
+
+ this.updateTimeRangeDisplay(timeRange);
+ }
+
+ /**
+ * Update time range display
+ */
+ private updateTimeRangeDisplay(container: HTMLElement): void {
+ const startInput = container.querySelector('.range-start');
+ const endInput = container.querySelector('.range-end');
+ const fill = container.querySelector('swp-time-range-fill');
+ const timesEl = container.querySelector('swp-time-range-times');
+ const durationEl = container.querySelector('swp-time-range-duration');
+
+ if (!startInput || !endInput) return;
+
+ let startVal = parseInt(startInput.value);
+ let endVal = parseInt(endInput.value);
+
+ // Ensure start doesn't exceed end
+ if (startVal > endVal) {
+ if (startInput === document.activeElement) {
+ startInput.value = String(endVal);
+ startVal = endVal;
+ } else {
+ endInput.value = String(startVal);
+ endVal = startVal;
+ }
+ }
+
+ // Update fill bar
+ if (fill) {
+ const startPercent = (startVal / this.TIME_RANGE_MAX) * 100;
+ const endPercent = (endVal / this.TIME_RANGE_MAX) * 100;
+ fill.style.left = startPercent + '%';
+ fill.style.width = (endPercent - startPercent) + '%';
+ }
+
+ // Calculate duration
+ const durationMinutes = (endVal - startVal) * 15;
+ const durationHours = durationMinutes / 60;
+ const durationText = durationHours % 1 === 0
+ ? `${durationHours} timer`
+ : `${durationHours.toFixed(1).replace('.', ',')} timer`;
+
+ if (timesEl) timesEl.textContent = `${this.valueToTime(startVal)} – ${this.valueToTime(endVal)}`;
+ if (durationEl) durationEl.textContent = durationText;
+ }
+
+ /**
+ * Setup time range slider
+ */
+ private setupTimeRangeSlider(): void {
+ const timeRange = document.getElementById('scheduleTimeRange');
+ if (!timeRange) return;
+
+ const startInput = timeRange.querySelector('.range-start');
+ const endInput = timeRange.querySelector('.range-end');
+ const fill = timeRange.querySelector('swp-time-range-fill');
+ const track = timeRange.querySelector('swp-time-range-track');
+
+ this.updateTimeRangeDisplay(timeRange);
+
+ startInput?.addEventListener('input', () => {
+ this.updateTimeRangeDisplay(timeRange);
+ this.updateSelectedCellsTime();
+ });
+
+ endInput?.addEventListener('input', () => {
+ this.updateTimeRangeDisplay(timeRange);
+ this.updateSelectedCellsTime();
+ });
+
+ // Drag fill bar to move entire range
+ if (fill && track && startInput && endInput) {
+ let isDragging = false;
+ let dragStartX = 0;
+ let dragStartValues = { start: 0, end: 0 };
+
+ fill.addEventListener('mousedown', (e: MouseEvent) => {
+ isDragging = true;
+ dragStartX = e.clientX;
+ dragStartValues.start = parseInt(startInput.value);
+ dragStartValues.end = parseInt(endInput.value);
+ e.preventDefault();
+ });
+
+ document.addEventListener('mousemove', (e: MouseEvent) => {
+ if (!isDragging) return;
+
+ const sliderWidth = track.offsetWidth;
+ const deltaX = e.clientX - dragStartX;
+ const deltaValue = Math.round((deltaX / sliderWidth) * this.TIME_RANGE_MAX);
+
+ const duration = dragStartValues.end - dragStartValues.start;
+ let newStart = dragStartValues.start + deltaValue;
+ let newEnd = dragStartValues.end + deltaValue;
+
+ if (newStart < 0) {
+ newStart = 0;
+ newEnd = duration;
+ }
+ if (newEnd > this.TIME_RANGE_MAX) {
+ newEnd = this.TIME_RANGE_MAX;
+ newStart = this.TIME_RANGE_MAX - duration;
+ }
+
+ startInput.value = String(newStart);
+ endInput.value = String(newEnd);
+ this.updateTimeRangeDisplay(timeRange);
+ this.updateSelectedCellsTime();
+ });
+
+ document.addEventListener('mouseup', () => {
+ isDragging = false;
+ });
+ }
+ }
+
+ /**
+ * Update selected cells with current time
+ */
+ private updateSelectedCellsTime(): void {
+ const selectedStatus = document.querySelector('#scheduleStatusOptions swp-status-option.selected') as HTMLElement;
+ const status = selectedStatus?.dataset.status || 'work';
+
+ if (status !== 'work') return;
+
+ const timeRange = document.getElementById('scheduleTimeRange');
+ if (!timeRange) return;
+
+ const startInput = timeRange.querySelector('.range-start');
+ const endInput = timeRange.querySelector('.range-end');
+ if (!startInput || !endInput) return;
+
+ const startTime = this.valueToTime(parseInt(startInput.value));
+ const endTime = this.valueToTime(parseInt(endInput.value));
+ const formattedTime = `${startTime} - ${endTime}`;
+
+ this.selectedCells.forEach(cell => {
+ const timeDisplay = cell.querySelector('swp-time-display');
+ if (timeDisplay && !timeDisplay.classList.contains('off') &&
+ !timeDisplay.classList.contains('vacation') &&
+ !timeDisplay.classList.contains('sick')) {
+ timeDisplay.textContent = formattedTime;
+ }
+ });
+ }
+
+ /**
+ * Update selected cells with current status
+ */
+ private updateSelectedCellsStatus(): void {
+ const selectedStatus = document.querySelector('#scheduleStatusOptions swp-status-option.selected') as HTMLElement;
+ const status = selectedStatus?.dataset.status || 'work';
+
+ const timeRange = document.getElementById('scheduleTimeRange');
+ const startInput = timeRange?.querySelector('.range-start');
+ const endInput = timeRange?.querySelector('.range-end');
+
+ const startTime = startInput ? this.valueToTime(parseInt(startInput.value)) : '09:00';
+ const endTime = endInput ? this.valueToTime(parseInt(endInput.value)) : '17:00';
+
+ this.selectedCells.forEach(cell => {
+ const timeDisplay = cell.querySelector('swp-time-display');
+ if (!timeDisplay) return;
+
+ timeDisplay.classList.remove('off', 'off-override', 'vacation', 'sick');
+
+ switch (status) {
+ case 'work':
+ timeDisplay.textContent = `${startTime} - ${endTime}`;
+ break;
+ case 'off':
+ timeDisplay.classList.add('off');
+ timeDisplay.textContent = '—';
+ break;
+ case 'vacation':
+ timeDisplay.classList.add('vacation');
+ timeDisplay.textContent = 'Ferie';
+ break;
+ case 'sick':
+ timeDisplay.classList.add('sick');
+ timeDisplay.textContent = 'Syg';
+ break;
+ }
+ });
+ }
+
+ /**
+ * Setup drawer save button
+ */
+ private setupDrawerSave(): void {
+ const saveBtn = document.getElementById('scheduleDrawerSave');
+ saveBtn?.addEventListener('click', () => {
+ // Changes are already applied in real-time
+ this.clearSelection();
+ this.showEmptyState();
+ });
+ }
+
+ /**
+ * Open the schedule drawer
+ */
+ private openDrawer(): void {
+ this.drawer?.classList.add('open');
+ document.getElementById('drawerOverlay')?.classList.add('active');
+ document.body.style.overflow = 'hidden';
+ }
+
+ /**
+ * Close the schedule drawer
+ */
+ private closeDrawer(): void {
+ this.drawer?.classList.remove('open');
+ document.getElementById('drawerOverlay')?.classList.remove('active');
+ document.body.style.overflow = '';
+ }
+}
diff --git a/PlanTempus.Application/wwwroot/ts/types/WorkSchedule.ts b/PlanTempus.Application/wwwroot/ts/types/WorkSchedule.ts
new file mode 100644
index 0000000..bf53852
--- /dev/null
+++ b/PlanTempus.Application/wwwroot/ts/types/WorkSchedule.ts
@@ -0,0 +1,30 @@
+/**
+ * Work Schedule Types
+ *
+ * Types for employee work schedule (arbejdstidsplan)
+ */
+
+export type ShiftStatus = 'work' | 'off' | 'vacation' | 'sick';
+
+export interface WorkScheduleShift {
+ status: ShiftStatus;
+ start?: string; // "09:00" - only when status=work
+ end?: string; // "17:00" - only when status=work
+ note?: string;
+}
+
+export interface EmployeeSchedule {
+ employeeId: string;
+ name: string;
+ weeklyHours: number;
+ schedule: Record; // key = ISO date "2025-12-23"
+}
+
+export interface WeekSchedule {
+ weekNumber: number;
+ year: number;
+ startDate: string; // ISO date "2025-12-23"
+ endDate: string; // ISO date "2025-12-29"
+ closedDays: string[]; // ["2025-12-25"]
+ employees: EmployeeSchedule[];
+}