Adds employee work schedule component
Introduces a new work schedule feature for managing employee shifts and schedules Implements interactive schedule view with: - Week-based schedule grid - Shift status tracking (work, vacation, sick, off) - Editable time ranges - Repeat shift functionality Enhances employee management with dynamic scheduling capabilities
This commit is contained in:
parent
d5a803ba80
commit
3214cbdc16
11 changed files with 1669 additions and 0 deletions
BIN
.workbench/drawer-worktime.png
Normal file
BIN
.workbench/drawer-worktime.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 260 KiB After Width: | Height: | Size: 260 KiB |
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
<swp-schedule-container>
|
||||||
|
<!-- Edit button -->
|
||||||
|
<swp-schedule-actions>
|
||||||
|
<swp-btn class="primary" id="scheduleEditBtn">
|
||||||
|
<i class="ph ph-pencil-simple"></i>
|
||||||
|
@Model.ButtonEdit
|
||||||
|
</swp-btn>
|
||||||
|
</swp-schedule-actions>
|
||||||
|
|
||||||
|
<!-- Schedule table -->
|
||||||
|
<swp-schedule-table id="scheduleTable">
|
||||||
|
<!-- Header row -->
|
||||||
|
<swp-schedule-cell class="header week-number">@Model.LabelWeek @Model.WeekNumber</swp-schedule-cell>
|
||||||
|
@foreach (var day in Model.Days)
|
||||||
|
{
|
||||||
|
<swp-schedule-cell class="header @(IsClosed(day.Date) ? "closed" : "")" data-date="@day.Date">
|
||||||
|
<swp-day-name>@day.DayName</swp-day-name>
|
||||||
|
<swp-day-date>@day.DisplayDate</swp-day-date>
|
||||||
|
</swp-schedule-cell>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Employee rows -->
|
||||||
|
@foreach (var employee in Model.Employees)
|
||||||
|
{
|
||||||
|
<swp-schedule-cell class="employee">
|
||||||
|
<swp-employee-name>@employee.Name</swp-employee-name>
|
||||||
|
<swp-employee-hours>@employee.WeeklyHours @Model.LabelHours</swp-employee-hours>
|
||||||
|
</swp-schedule-cell>
|
||||||
|
|
||||||
|
@foreach (var day in Model.Days)
|
||||||
|
{
|
||||||
|
var shift = employee.Schedule.GetValueOrDefault(day.Date);
|
||||||
|
var isClosed = IsClosed(day.Date);
|
||||||
|
<swp-schedule-cell class="day @(isClosed ? "closed-day" : "")"
|
||||||
|
data-employee="@employee.Name"
|
||||||
|
data-employee-id="@employee.EmployeeId"
|
||||||
|
data-date="@day.Date"
|
||||||
|
data-day="@day.ShortDayName">
|
||||||
|
<swp-time-display class="@GetTimeClass(shift)">@GetTimeDisplay(shift)</swp-time-display>
|
||||||
|
</swp-schedule-cell>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</swp-schedule-table>
|
||||||
|
</swp-schedule-container>
|
||||||
|
|
||||||
|
<!-- Schedule Drawer -->
|
||||||
|
<div id="schedule-drawer" data-drawer="lg">
|
||||||
|
<swp-drawer-header>
|
||||||
|
<swp-drawer-title>@Model.LabelEditShift</swp-drawer-title>
|
||||||
|
<swp-drawer-close data-drawer-close>
|
||||||
|
<i class="ph ph-x"></i>
|
||||||
|
</swp-drawer-close>
|
||||||
|
</swp-drawer-header>
|
||||||
|
|
||||||
|
<swp-drawer-body>
|
||||||
|
<!-- Employee & Date -->
|
||||||
|
<swp-form-row>
|
||||||
|
<swp-form-label>Medarbejder</swp-form-label>
|
||||||
|
<swp-employee-display id="scheduleFieldEmployee" class="empty">
|
||||||
|
<swp-employee-avatar id="scheduleFieldAvatar">?</swp-employee-avatar>
|
||||||
|
<swp-form-value id="scheduleFieldEmployeeName">Vælg celle...</swp-form-value>
|
||||||
|
</swp-employee-display>
|
||||||
|
</swp-form-row>
|
||||||
|
|
||||||
|
<swp-form-row>
|
||||||
|
<swp-form-label>Dato</swp-form-label>
|
||||||
|
<swp-form-value id="scheduleFieldDate">—</swp-form-value>
|
||||||
|
</swp-form-row>
|
||||||
|
|
||||||
|
<swp-form-divider></swp-form-divider>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<swp-form-row>
|
||||||
|
<swp-form-label>@Model.LabelStatus</swp-form-label>
|
||||||
|
<swp-status-options id="scheduleStatusOptions">
|
||||||
|
<swp-status-option data-status="work" class="selected">@Model.LabelWork</swp-status-option>
|
||||||
|
<swp-status-option data-status="off">@Model.LabelOff</swp-status-option>
|
||||||
|
<swp-status-option data-status="vacation">@Model.LabelVacation</swp-status-option>
|
||||||
|
<swp-status-option data-status="sick">@Model.LabelSick</swp-status-option>
|
||||||
|
</swp-status-options>
|
||||||
|
</swp-form-row>
|
||||||
|
|
||||||
|
<!-- Time range -->
|
||||||
|
<swp-form-row id="scheduleTimeRow">
|
||||||
|
<swp-form-label>@Model.LabelTimeRange</swp-form-label>
|
||||||
|
<swp-time-range id="scheduleTimeRange">
|
||||||
|
<swp-time-range-slider>
|
||||||
|
<swp-time-range-track></swp-time-range-track>
|
||||||
|
<swp-time-range-fill></swp-time-range-fill>
|
||||||
|
<input type="range" class="range-start" min="0" max="60" value="12" step="1">
|
||||||
|
<input type="range" class="range-end" min="0" max="60" value="44" step="1">
|
||||||
|
</swp-time-range-slider>
|
||||||
|
<swp-time-range-label>
|
||||||
|
<swp-time-range-times>09:00 – 17:00</swp-time-range-times>
|
||||||
|
<swp-time-range-duration>8 timer</swp-time-range-duration>
|
||||||
|
</swp-time-range-label>
|
||||||
|
</swp-time-range>
|
||||||
|
</swp-form-row>
|
||||||
|
|
||||||
|
<!-- Note -->
|
||||||
|
<swp-form-row>
|
||||||
|
<swp-form-label>@Model.LabelNote <span class="optional">(valgfrit)</span></swp-form-label>
|
||||||
|
<input type="text" id="scheduleFieldNote" placeholder="F.eks. Aftenvagt">
|
||||||
|
</swp-form-row>
|
||||||
|
|
||||||
|
<swp-form-divider></swp-form-divider>
|
||||||
|
|
||||||
|
<!-- Type toggle -->
|
||||||
|
<swp-form-row>
|
||||||
|
<swp-form-label>@Model.LabelType</swp-form-label>
|
||||||
|
<swp-toggle-options id="scheduleTypeOptions">
|
||||||
|
<swp-toggle-option data-value="single">@Model.LabelSingle</swp-toggle-option>
|
||||||
|
<swp-toggle-option data-value="template" class="selected">@Model.LabelRepeat</swp-toggle-option>
|
||||||
|
</swp-toggle-options>
|
||||||
|
</swp-form-row>
|
||||||
|
|
||||||
|
<!-- Repeat settings -->
|
||||||
|
<swp-form-group id="scheduleRepeatGroup">
|
||||||
|
<swp-form-row>
|
||||||
|
<swp-form-label>@Model.LabelRepeatInterval</swp-form-label>
|
||||||
|
<swp-form-select>
|
||||||
|
<select id="scheduleRepeatInterval">
|
||||||
|
<option value="1">Hver uge</option>
|
||||||
|
<option value="2">Hver 2. uge</option>
|
||||||
|
<option value="3">Hver 3. uge</option>
|
||||||
|
<option value="4">Hver 4. uge</option>
|
||||||
|
</select>
|
||||||
|
</swp-form-select>
|
||||||
|
</swp-form-row>
|
||||||
|
|
||||||
|
<swp-form-hint>Gentagelser bruger valgt dato som startuge.</swp-form-hint>
|
||||||
|
|
||||||
|
<swp-form-row>
|
||||||
|
<swp-form-label>@Model.LabelRepeatEnd <span class="optional">(valgfrit)</span></swp-form-label>
|
||||||
|
<input type="date" id="scheduleRepeatEndDate">
|
||||||
|
</swp-form-row>
|
||||||
|
|
||||||
|
<swp-form-row>
|
||||||
|
<swp-form-label>@Model.LabelWeekday <span class="auto">(auto)</span></swp-form-label>
|
||||||
|
<swp-form-value id="scheduleFieldWeekday">—</swp-form-value>
|
||||||
|
</swp-form-row>
|
||||||
|
</swp-form-group>
|
||||||
|
</swp-drawer-body>
|
||||||
|
|
||||||
|
<swp-drawer-footer>
|
||||||
|
<swp-btn class="secondary" data-drawer-close>@Model.LabelCancel</swp-btn>
|
||||||
|
<swp-btn class="primary" id="scheduleDrawerSave">@Model.LabelSave</swp-btn>
|
||||||
|
</swp-drawer-footer>
|
||||||
|
</div>
|
||||||
|
|
@ -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<WeekScheduleData>(json, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
})!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<DayInfo> 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<DayInfo>();
|
||||||
|
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<string> ClosedDays { get; init; }
|
||||||
|
public required List<EmployeeScheduleData> Employees { get; init; }
|
||||||
|
public required List<DayInfo> 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<string> ClosedDays { get; init; }
|
||||||
|
public required List<EmployeeScheduleData> 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<string, ShiftData> 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; }
|
||||||
|
}
|
||||||
|
|
@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -35,6 +35,10 @@
|
||||||
<i class="ph ph-shield-check"></i>
|
<i class="ph ph-shield-check"></i>
|
||||||
<span localize="employees.tabs.roles">Roller</span>
|
<span localize="employees.tabs.roles">Roller</span>
|
||||||
</swp-tab>
|
</swp-tab>
|
||||||
|
<swp-tab data-tab="schedule">
|
||||||
|
<i class="ph ph-calendar-dots"></i>
|
||||||
|
<span localize="employees.detail.tabs.schedule">Vagtplan</span>
|
||||||
|
</swp-tab>
|
||||||
</swp-tab-bar>
|
</swp-tab-bar>
|
||||||
</swp-sticky-header>
|
</swp-sticky-header>
|
||||||
|
|
||||||
|
|
@ -51,6 +55,13 @@
|
||||||
@await Component.InvokeAsync("PermissionsMatrix", "default")
|
@await Component.InvokeAsync("PermissionsMatrix", "default")
|
||||||
</swp-page-container>
|
</swp-page-container>
|
||||||
</swp-tab-content>
|
</swp-tab-content>
|
||||||
|
|
||||||
|
<!-- Tab: Schedule -->
|
||||||
|
<swp-tab-content data-tab="schedule">
|
||||||
|
<swp-page-container>
|
||||||
|
@await Component.InvokeAsync("EmployeeWorkSchedule", "default")
|
||||||
|
</swp-page-container>
|
||||||
|
</swp-tab-content>
|
||||||
</swp-employees-list-view>
|
</swp-employees-list-view>
|
||||||
|
|
||||||
<!-- Detail View (hidden by default, shown when row clicked) -->
|
<!-- Detail View (hidden by default, shown when row clicked) -->
|
||||||
|
|
|
||||||
|
|
@ -271,11 +271,32 @@
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"general": "Generelt",
|
"general": "Generelt",
|
||||||
"hours": "Arbejdstid",
|
"hours": "Arbejdstid",
|
||||||
|
"schedule": "Vagtplan",
|
||||||
"services": "Services",
|
"services": "Services",
|
||||||
"salary": "Løn",
|
"salary": "Løn",
|
||||||
"hr": "HR",
|
"hr": "HR",
|
||||||
"stats": "Statistik"
|
"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",
|
"contact": "Kontaktoplysninger",
|
||||||
"personal": "Personlige oplysninger",
|
"personal": "Personlige oplysninger",
|
||||||
"employment": "Ansættelse",
|
"employment": "Ansættelse",
|
||||||
|
|
|
||||||
|
|
@ -271,11 +271,32 @@
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"hours": "Working hours",
|
"hours": "Working hours",
|
||||||
|
"schedule": "Schedule",
|
||||||
"services": "Services",
|
"services": "Services",
|
||||||
"salary": "Salary",
|
"salary": "Salary",
|
||||||
"hr": "HR",
|
"hr": "HR",
|
||||||
"stats": "Statistics"
|
"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",
|
"contact": "Contact information",
|
||||||
"personal": "Personal information",
|
"personal": "Personal information",
|
||||||
"employment": "Employment",
|
"employment": "Employment",
|
||||||
|
|
|
||||||
|
|
@ -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
|
RESPONSIVE
|
||||||
=========================================== */
|
=========================================== */
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
export class EmployeesController {
|
export class EmployeesController {
|
||||||
private ratesSync: RatesSyncController | null = null;
|
private ratesSync: RatesSyncController | null = null;
|
||||||
|
private scheduleController: ScheduleController | null = null;
|
||||||
private listView: HTMLElement | null = null;
|
private listView: HTMLElement | null = null;
|
||||||
private detailView: HTMLElement | null = null;
|
private detailView: HTMLElement | null = null;
|
||||||
|
|
||||||
|
|
@ -25,6 +26,7 @@ export class EmployeesController {
|
||||||
this.setupHistoryNavigation();
|
this.setupHistoryNavigation();
|
||||||
this.restoreStateFromUrl();
|
this.restoreStateFromUrl();
|
||||||
this.ratesSync = new RatesSyncController();
|
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<HTMLElement>('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<HTMLElement>('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<string, string> = {
|
||||||
|
'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<HTMLInputElement>('.range-start');
|
||||||
|
const endInput = timeRange.querySelector<HTMLInputElement>('.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<HTMLInputElement>('.range-start');
|
||||||
|
const endInput = container.querySelector<HTMLInputElement>('.range-end');
|
||||||
|
const fill = container.querySelector<HTMLElement>('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<HTMLInputElement>('.range-start');
|
||||||
|
const endInput = timeRange.querySelector<HTMLInputElement>('.range-end');
|
||||||
|
const fill = timeRange.querySelector<HTMLElement>('swp-time-range-fill');
|
||||||
|
const track = timeRange.querySelector<HTMLElement>('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<HTMLInputElement>('.range-start');
|
||||||
|
const endInput = timeRange.querySelector<HTMLInputElement>('.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<HTMLInputElement>('.range-start');
|
||||||
|
const endInput = timeRange?.querySelector<HTMLInputElement>('.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 = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
30
PlanTempus.Application/wwwroot/ts/types/WorkSchedule.ts
Normal file
30
PlanTempus.Application/wwwroot/ts/types/WorkSchedule.ts
Normal file
|
|
@ -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<string, WorkScheduleShift>; // 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[];
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue