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