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:
Janus C. H. Knudsen 2026-01-14 22:47:40 +01:00
parent d5a803ba80
commit 3214cbdc16
11 changed files with 1669 additions and 0 deletions

View file

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

View file

@ -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; }
}

View file

@ -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" }
}
}
]
}

View file

@ -35,6 +35,10 @@
<i class="ph ph-shield-check"></i>
<span localize="employees.tabs.roles">Roller</span>
</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-sticky-header>
@ -51,6 +55,13 @@
@await Component.InvokeAsync("PermissionsMatrix", "default")
</swp-page-container>
</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>
<!-- Detail View (hidden by default, shown when row clicked) -->