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
|
|
@ -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>
|
||||
<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) -->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue