feat: add WorkUnitEditorModal component for managing work units

- Implemented WorkUnitEditorModal.razor for creating and editing work units.
- Added necessary services and parameters for data handling.
- Included computed values for calculated hours, gross income, and net income.
- Enhanced UI with modal structure and styling.

fix: update _Imports.razor to include Shared components

- Added reference to WorkUnitEditorModal in _Imports.razor for accessibility.

feat: extend CalendarEventDocument with StartDate and EndDate properties

- Updated CalendarEventDocument.cs to include StartDate and EndDate for better event management.

feat: create CalendarEventFormatter for event description formatting

- Introduced CalendarEventFormatter.cs to handle display logic for calendar events.

fix: enhance CouchbaseLiteWorkDayService for calendar event management

- Updated methods to handle new StartDate and EndDate properties in calendar events.
- Improved event saving and deletion logic.

test: add Playwright tests for date locale functionality

- Created date-locale.spec.ts to verify date picker behavior and formatting.

style: enhance app.css with modal and date input styles

- Added styles for calendar modal, date input, and related components for improved UI.
This commit is contained in:
Marco 2026-04-22 11:07:30 +02:00
commit bc28d869eb
14 changed files with 1638 additions and 150 deletions

View file

@ -20,8 +20,14 @@ else
{
<div class="row g-3">
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">Date</label>
<input type="date" class="form-control" value="@selectedDate.ToString("yyyy-MM-dd")" @onchange="OnDateChanged" disabled="@isExistingEvent" />
<label class="form-label">Start Date</label>
<LocalizedDateInput InputId="calendar-event-date" TestId="calendar-event-start-date" Value="@selectedDate" ValueChanged="OnDateChangedAsync" />
</div>
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">End Date</label>
<LocalizedDateInput InputId="calendar-event-end-date" TestId="calendar-event-end-date" Value="@endDate" ValueChanged="OnEndDateChangedAsync" AllowEmpty="true" />
<div class="form-text">Optional. Leave empty for a single-day event.</div>
</div>
<div class="col-12 col-md-6 col-lg-4">
@ -29,24 +35,24 @@ else
<select class="form-select" @bind="eventType">
@foreach (var item in Enum.GetValues<CalendarEventType>())
{
<option value="@item">@item</option>
<option value="@item">@CalendarEventFormatter.GetEventTypeName(item)</option>
}
</select>
</div>
<div class="col-12 col-lg-8">
<label class="form-label">Description</label>
<input class="form-control" @bind="description" maxlength="120" />
<label class="form-label">Title</label>
<input class="form-control" @bind="description" maxlength="120" placeholder="Optional" />
</div>
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">Start Time</label>
<input type="time" class="form-control" value="@startTimeStr" @onchange="OnStartTimeChanged" />
<input type="time" lang="it-IT" class="form-control" value="@startTimeStr" @onchange="OnStartTimeChanged" />
</div>
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">End Time</label>
<input type="time" class="form-control" value="@endTimeStr" @onchange="OnEndTimeChanged" />
<input type="time" lang="it-IT" class="form-control" value="@endTimeStr" @onchange="OnEndTimeChanged" />
</div>
<div class="col-12 col-md-6 col-lg-4">
@ -78,9 +84,10 @@ else
private DateOnly selectedDate = DateOnly.FromDateTime(DateTime.Today);
private string eventId = string.Empty;
private CalendarEventType eventType = CalendarEventType.Generic;
private string description = "Calendar entry";
private string description = string.Empty;
private string? startTimeStr;
private string? endTimeStr;
private DateOnly? endDate;
private string? statusMessage;
protected override async Task OnInitializedAsync()
@ -106,10 +113,12 @@ else
if (existing is not null)
{
eventId = existing.Id;
selectedDate = existing.StartDate == default ? selectedDate : existing.StartDate;
eventType = existing.EventType;
description = existing.Description;
startTimeStr = existing.StartTime?.ToString("HH:mm");
endTimeStr = existing.EndTime?.ToString("HH:mm");
endDate = existing.EndDate;
isExistingEvent = true;
}
else
@ -123,23 +132,31 @@ else
{
eventId = string.Empty;
eventType = CalendarEventType.Generic;
description = "Calendar entry";
description = string.Empty;
startTimeStr = null;
endTimeStr = null;
endDate = null;
isExistingEvent = false;
}
private Task OnDateChanged(ChangeEventArgs e)
private Task OnDateChangedAsync(DateOnly? value)
{
if (DateOnly.TryParse(e.Value?.ToString(), out var parsed))
if (value.HasValue)
{
selectedDate = parsed;
selectedDate = value.Value;
statusMessage = null;
}
return Task.CompletedTask;
}
private Task OnEndDateChangedAsync(DateOnly? value)
{
endDate = value;
statusMessage = null;
return Task.CompletedTask;
}
private Task OnStartTimeChanged(ChangeEventArgs e)
{
startTimeStr = e.Value?.ToString();
@ -159,6 +176,8 @@ else
var calendarEvent = new CalendarEventDocument
{
Id = eventId,
StartDate = selectedDate,
EndDate = endDate,
EventType = eventType,
Description = description,
StartTime = ParseTime(startTimeStr),
@ -180,7 +199,8 @@ else
return;
}
var confirmed = await JS.InvokeAsync<bool>("confirm", $"Delete calendar event '{description}' on {selectedDate:dddd d MMMM}?\nThis cannot be undone.");
var eventName = CalendarEventFormatter.GetDisplayDescription(eventType, description);
var confirmed = await JS.InvokeAsync<bool>("confirm", $"Delete calendar event '{eventName}' starting on {FormatDisplayDate(selectedDate)}?\nThis cannot be undone.");
if (!confirmed)
{
return;
@ -229,4 +249,9 @@ else
? parsed
: null;
}
private static string FormatDisplayDate(DateOnly date)
{
return date.ToString("dddd dd/MM/yyyy");
}
}

View file

@ -5,7 +5,6 @@
@inject IWorkDayService WorkDayService
@inject IItalianFestivitySource FestivitySource
@inject NavigationManager Navigation
@inject IJSRuntime JS
<PageTitle>Calendar</PageTitle>
@ -50,76 +49,75 @@ else
<tr>
@foreach (var cell in week)
{
@if (cell is null)
{
<td class="calendar-cell calendar-cell-empty"></td>
}
else
{
<td class="calendar-cell @GetCellClass(cell) @(IsToday(cell.Date) ? "calendar-cell-today" : string.Empty) @(IsActiveCell(cell.Date) ? "calendar-cell-active" : string.Empty)" @onclick="() => TogglePopup(cell.Date)" role="button">
<div class="calendar-day-number">@cell.Date.Day</div>
@foreach (var workUnit in cell.Entry?.WorkUnits ?? [])
<td class="calendar-cell @GetCellClass(cell) @(cell.IsCurrentMonth ? string.Empty : "calendar-cell-outside-month") @(IsToday(cell.Date) ? "calendar-cell-today" : string.Empty) @(IsActiveCell(cell.Date) ? "calendar-cell-active" : string.Empty)" @onclick="() => TogglePopup(cell.Date)" role="button">
<div class="d-flex align-items-start justify-content-between gap-2">
<div class="calendar-day-number @(cell.IsCurrentMonth ? string.Empty : "calendar-day-number-outside")">@cell.Date.Day</div>
@if (!cell.IsCurrentMonth)
{
<button type="button" class="calendar-item calendar-item-work @GetWorkUnitClass(workUnit)" @onclick:stopPropagation="true" @onclick="() => OpenWorkUnit(cell.Date, workUnit.Id)">
<span>@workUnit.Label</span>
<span>@FormatWorkUnit(workUnit)</span>
</button>
<div class="calendar-day-month-label">@cell.Date.ToString("MMM")</div>
}
</div>
@foreach (var calendarEvent in cell.Entry?.CalendarEvents ?? [])
{
<button type="button" class="calendar-item calendar-item-event @GetCalendarEventClass(calendarEvent)" @onclick:stopPropagation="true" @onclick="() => OpenCalendarEvent(cell.Date, calendarEvent.Id)">
<span>@calendarEvent.Description</span>
@if (calendarEvent.StartTime.HasValue)
{
<span>@calendarEvent.StartTime.Value.ToString("HH:mm")</span>
}
</button>
}
@foreach (var workUnit in cell.Entry?.WorkUnits ?? [])
{
<button type="button" class="calendar-item calendar-item-work @GetWorkUnitClass(workUnit)" @onclick:stopPropagation="true" @onclick="() => OpenWorkUnit(cell.Date, workUnit.Id)">
<span>@workUnit.Label</span>
<span>@FormatWorkUnit(workUnit)</span>
</button>
}
<div class="calendar-day-total">@FormatHours(GetDayTotalHours(cell.Entry, includePreviewTotals))</div>
@foreach (var calendarEvent in cell.Entry?.CalendarEvents ?? [])
{
<button type="button" class="calendar-item calendar-item-event @GetCalendarEventClass(calendarEvent)" @onclick:stopPropagation="true" @onclick="() => OpenCalendarEvent(GetCalendarEventOwnerDate(calendarEvent, cell.Date), calendarEvent.Id)">
<span>@CalendarEventFormatter.GetDisplayDescription(calendarEvent)</span>
@if (calendarEvent.StartTime.HasValue)
{
<span>@calendarEvent.StartTime.Value.ToString("HH:mm")</span>
}
</button>
}
@if (IsActiveCell(cell.Date))
{
<div class="calendar-popup @GetPopupClass(cell)" @onclick:stopPropagation="true">
<div class="d-flex align-items-start justify-content-between gap-2 mb-2">
<div>
<div class="fw-semibold">@cell.Date.ToString("dddd d MMMM")</div>
<div class="small text-muted">Select an existing entry or create a new one.</div>
</div>
<button type="button" class="btn-close btn-sm" aria-label="Close" @onclick="ClosePopup"></button>
</div>
<div class="calendar-popup-section">
@if ((cell.Entry?.WorkUnits.Count ?? 0) == 0 && (cell.Entry?.CalendarEvents.Count ?? 0) == 0)
{
<div class="small text-muted">No entries for this day.</div>
}
@foreach (var workUnit in cell.Entry?.WorkUnits ?? [])
{
<button type="button" class="calendar-popup-link" @onclick="() => OpenWorkUnit(cell.Date, workUnit.Id)">
Work unit: @workUnit.Label (@FormatWorkUnit(workUnit))
</button>
}
@foreach (var calendarEvent in cell.Entry?.CalendarEvents ?? [])
{
<button type="button" class="calendar-popup-link" @onclick="() => OpenCalendarEvent(cell.Date, calendarEvent.Id)">
Calendar event: @calendarEvent.Description
</button>
}
</div>
<div class="d-flex gap-2 mt-3">
<button type="button" class="btn btn-sm btn-primary" @onclick="() => CreateWorkUnit(cell.Date)">New Work Unit</button>
<button type="button" class="btn btn-sm btn-outline-primary" @onclick="() => CreateCalendarEvent(cell.Date)">New Calendar Event</button>
<div class="calendar-day-total">@FormatHours(GetDayTotalHours(cell.Entry, includePreviewTotals))</div>
@if (IsActiveCell(cell.Date))
{
<div class="calendar-popup @GetPopupClass(cell)" @onclick:stopPropagation="true">
<div class="d-flex align-items-start justify-content-between gap-2 mb-2">
<div>
<div class="fw-semibold">@FormatDisplayDate(cell.Date)</div>
<div class="small text-muted">Select an existing entry or create a new one.</div>
</div>
<button type="button" class="btn-close btn-sm" aria-label="Close" @onclick="ClosePopup"></button>
</div>
}
</td>
}
<div class="calendar-popup-section">
@if ((cell.Entry?.WorkUnits.Count ?? 0) == 0 && (cell.Entry?.CalendarEvents.Count ?? 0) == 0)
{
<div class="small text-muted">No entries for this day.</div>
}
@foreach (var workUnit in cell.Entry?.WorkUnits ?? [])
{
<button type="button" class="calendar-popup-link" @onclick="() => OpenWorkUnit(cell.Date, workUnit.Id)">
Work unit: @workUnit.Label (@FormatWorkUnit(workUnit))
</button>
}
@foreach (var calendarEvent in cell.Entry?.CalendarEvents ?? [])
{
<button type="button" class="calendar-popup-link" @onclick="() => OpenCalendarEvent(GetCalendarEventOwnerDate(calendarEvent, cell.Date), calendarEvent.Id)">
Calendar event: @CalendarEventFormatter.GetDisplayDescription(calendarEvent)
</button>
}
</div>
<div class="d-flex gap-2 mt-3">
<button type="button" class="btn btn-sm btn-primary" @onclick="() => CreateWorkUnit(cell.Date)">New Work Unit</button>
<button type="button" class="btn btn-sm btn-outline-primary" @onclick="() => CreateCalendarEvent(cell.Date)">New Calendar Event</button>
</div>
</div>
}
</td>
}
</tr>
}
@ -194,6 +192,16 @@ else
</div>
}
}
@if (workUnitModalDate.HasValue)
{
<WorkUnitEditorModal Date="@workUnitModalDate.Value" UnitId="@workUnitModalId" OnSaved="HandleEditorSavedAsync" OnClosed="CloseWorkUnitModal" />
}
@if (calendarEventModalDate.HasValue)
{
<CalendarEventEditorModal Date="@calendarEventModalDate.Value" EventId="@calendarEventModalId" OnSaved="HandleEditorSavedAsync" OnClosed="CloseCalendarEventModal" />
}
</div>
@code {
@ -203,12 +211,16 @@ else
private DateOnly firstOfMonth;
private bool loading = true;
private List<CalendarCell?[]> weeks = [];
private IReadOnlyCollection<DateOnly> festivities = [];
private List<CalendarCell[]> weeks = [];
private readonly Dictionary<int, IReadOnlyCollection<DateOnly>> festivitiesByYear = [];
private DateOnly? activeDate;
private bool includePreviewTotals;
private MonthlySummaryModel? monthTotals;
private string? statusMessage;
private DateOnly? workUnitModalDate;
private string? workUnitModalId;
private DateOnly? calendarEventModalDate;
private string? calendarEventModalId;
protected override async Task OnInitializedAsync()
{
@ -244,41 +256,32 @@ else
{
loading = true;
activeDate = null;
festivities = FestivitySource.GetFestivities(firstOfMonth.Year);
var lastDay = firstOfMonth.AddMonths(1).AddDays(-1);
var entries = await WorkDayService.GetRangeAsync(firstOfMonth, lastDay);
var gridStart = GetGridStart(firstOfMonth);
var gridEnd = GetGridEnd(lastDay);
var entries = await WorkDayService.GetRangeAsync(gridStart, gridEnd);
var lookup = entries.ToDictionary(e => e.Date);
monthTotals = await WorkDayService.GetMonthlySummaryAsync(firstOfMonth.Year, firstOfMonth.Month, includePreviewTotals);
// Build calendar grid (ISO weeks: Monday = 0)
weeks = [];
var currentWeek = new CalendarCell?[7];
var dayOfWeek = ((int)firstOfMonth.DayOfWeek + 6) % 7; // Mon=0
for (var d = firstOfMonth; d <= lastDay; d = d.AddDays(1))
for (var weekStart = gridStart; weekStart <= gridEnd; weekStart = weekStart.AddDays(7))
{
currentWeek[dayOfWeek] = new CalendarCell
var week = new CalendarCell[7];
for (var columnIndex = 0; columnIndex < 7; columnIndex++)
{
Date = d,
ColumnIndex = dayOfWeek,
IsWeekend = d.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday,
IsFestivity = festivities.Contains(d),
Entry = lookup.GetValueOrDefault(d)
};
dayOfWeek++;
if (dayOfWeek == 7)
{
weeks.Add(currentWeek);
currentWeek = new CalendarCell?[7];
dayOfWeek = 0;
var date = weekStart.AddDays(columnIndex);
week[columnIndex] = new CalendarCell
{
Date = date,
ColumnIndex = columnIndex,
IsCurrentMonth = date.Month == firstOfMonth.Month && date.Year == firstOfMonth.Year,
IsWeekend = date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday,
IsFestivity = GetFestivities(date.Year).Contains(date),
Entry = lookup.GetValueOrDefault(date)
};
}
}
if (dayOfWeek > 0)
{
weeks.Add(currentWeek);
weeks.Add(week);
}
loading = false;
@ -331,22 +334,46 @@ else
private void CreateWorkUnit(DateOnly date)
{
Navigation.NavigateTo($"/work-unit/{date:yyyy-MM-dd}");
OpenWorkUnit(date, null);
}
private void CreateCalendarEvent(DateOnly date)
{
Navigation.NavigateTo($"/calendar-event/{date:yyyy-MM-dd}");
OpenCalendarEvent(date, null);
}
private void OpenWorkUnit(DateOnly date, string workUnitId)
private void OpenWorkUnit(DateOnly date, string? workUnitId)
{
Navigation.NavigateTo($"/work-unit/{date:yyyy-MM-dd}/{workUnitId}");
activeDate = null;
workUnitModalDate = date;
workUnitModalId = workUnitId;
}
private void OpenCalendarEvent(DateOnly date, string eventId)
private void OpenCalendarEvent(DateOnly date, string? eventId)
{
Navigation.NavigateTo($"/calendar-event/{date:yyyy-MM-dd}/{eventId}");
activeDate = null;
calendarEventModalDate = date;
calendarEventModalId = eventId;
}
private async Task HandleEditorSavedAsync()
{
CloseWorkUnitModal();
CloseCalendarEventModal();
statusMessage = null;
await LoadMonth();
}
private void CloseWorkUnitModal()
{
workUnitModalDate = null;
workUnitModalId = null;
}
private void CloseCalendarEventModal()
{
calendarEventModalDate = null;
calendarEventModalId = null;
}
private async Task GeneratePreviewWorkUnitsAsync()
@ -465,10 +492,44 @@ else
return DurationFormatter.FormatHours(value);
}
private static string FormatDisplayDate(DateOnly date)
{
return date.ToString("dddd dd/MM/yyyy");
}
private IReadOnlyCollection<DateOnly> GetFestivities(int year)
{
if (!festivitiesByYear.TryGetValue(year, out var festivities))
{
festivities = FestivitySource.GetFestivities(year);
festivitiesByYear[year] = festivities;
}
return festivities;
}
private static DateOnly GetGridStart(DateOnly firstDayOfMonth)
{
var offset = ((int)firstDayOfMonth.DayOfWeek + 6) % 7;
return firstDayOfMonth.AddDays(-offset);
}
private static DateOnly GetGridEnd(DateOnly lastDayOfMonth)
{
var offset = (7 - (((int)lastDayOfMonth.DayOfWeek + 6) % 7) - 1 + 7) % 7;
return lastDayOfMonth.AddDays(offset);
}
private static DateOnly GetCalendarEventOwnerDate(CalendarEventDocument calendarEvent, DateOnly fallbackDate)
{
return calendarEvent.StartDate == default ? fallbackDate : calendarEvent.StartDate;
}
private sealed class CalendarCell
{
public DateOnly Date { get; set; }
public int ColumnIndex { get; set; }
public bool IsCurrentMonth { get; set; }
public bool IsWeekend { get; set; }
public bool IsFestivity { get; set; }
public WorkDayDocument? Entry { get; set; }

View file

@ -63,7 +63,7 @@ else
{
@foreach (var calendarEvent in row.Entry.CalendarEvents)
{
<div class="small mb-1">@calendarEvent.EventType: @calendarEvent.Description</div>
<div class="small mb-1">@CalendarEventFormatter.GetEventTypeName(calendarEvent.EventType): @CalendarEventFormatter.GetDisplayDescription(calendarEvent)</div>
}
}
else

View file

@ -22,7 +22,7 @@ else
<div class="row g-3">
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">Date</label>
<input type="date" class="form-control" value="@selectedDate.ToString("yyyy-MM-dd")" @onchange="OnDateChanged" disabled="@isExistingUnit" />
<LocalizedDateInput InputId="work-day-date" TestId="work-day-date" Value="@selectedDate" ValueChanged="OnDateChangedAsync" Disabled="@isExistingUnit" />
</div>
<div class="col-12 col-md-6 col-lg-4">
@ -42,12 +42,12 @@ else
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">Start Time</label>
<input type="time" class="form-control" value="@startTimeStr" @onchange="OnStartTimeChanged" />
<input type="time" lang="it-IT" class="form-control" value="@startTimeStr" @onchange="OnStartTimeChanged" />
</div>
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">End Time</label>
<input type="time" class="form-control" value="@endTimeStr" @onchange="OnEndTimeChanged" />
<input type="time" lang="it-IT" class="form-control" value="@endTimeStr" @onchange="OnEndTimeChanged" />
</div>
<div class="col-12 col-md-6 col-lg-4">
@ -191,11 +191,11 @@ else
RecomputePreview();
}
private async Task OnDateChanged(ChangeEventArgs e)
private async Task OnDateChangedAsync(DateOnly? value)
{
if (DateOnly.TryParse(e.Value?.ToString(), out var d))
if (value.HasValue)
{
selectedDate = d;
selectedDate = value.Value;
statusMessage = null;
}
}