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

@ -0,0 +1,246 @@
@using System.Globalization
<div class="localized-date-input" @onkeydown="HandleKeyDown">
<div class="input-group">
<input id="@InputId"
data-testid="@GetInputTestId()"
type="text"
class="form-control"
value="@displayValue"
@onchange="OnTextChangedAsync"
@onfocus="OpenPopup"
placeholder="dd/MM/yyyy"
inputmode="numeric"
autocomplete="off"
disabled="@Disabled" />
<button type="button"
class="btn btn-outline-secondary localized-date-input-toggle"
aria-label="Open calendar"
@onclick="TogglePopup"
disabled="@Disabled">
Cal
</button>
</div>
@if (isOpen && !Disabled)
{
<div class="localized-date-input-popover" data-testid="@GetPopoverTestId()" @onclick:stopPropagation="true">
<div class="localized-date-input-header">
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="ShowPreviousMonth" aria-label="Previous month">&lsaquo;</button>
<div class="localized-date-input-month">@visibleMonth.ToDateTime(TimeOnly.MinValue).ToString("MMMM yyyy", ItalianCulture)</div>
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="ShowNextMonth" aria-label="Next month">&rsaquo;</button>
</div>
<div class="localized-date-input-weekdays">
@foreach (var weekday in mondayFirstWeekdays)
{
<div class="localized-date-input-weekday" data-testid="date-picker-weekday">@weekday</div>
}
</div>
<div class="localized-date-input-grid">
@foreach (var day in calendarDays)
{
<button type="button"
class="localized-date-input-day @(day.IsCurrentMonth ? string.Empty : "localized-date-input-day-outside") @(day.Date == Value ? "localized-date-input-day-selected" : string.Empty)"
data-testid="@GetDayTestId(day.Date)"
@onclick="() => SelectDateAsync(day.Date)">
@day.Date.Day
</button>
}
</div>
@if (AllowEmpty)
{
<div class="localized-date-input-actions">
<button type="button" class="btn btn-sm btn-outline-secondary ms-auto" @onclick="ClearAsync">Clear</button>
</div>
}
</div>
}
</div>
@code {
private static readonly CultureInfo ItalianCulture = CultureInfo.GetCultureInfo("it-IT");
private static readonly string[] SupportedFormats = ["dd/MM/yyyy", "d/M/yyyy", "dd/M/yyyy", "d/MM/yyyy"];
private static readonly string[] mondayFirstWeekdays = BuildMondayFirstWeekdays();
[Parameter] public DateOnly? Value { get; set; }
[Parameter] public EventCallback<DateOnly?> ValueChanged { get; set; }
[Parameter] public bool Disabled { get; set; }
[Parameter] public bool AllowEmpty { get; set; }
[Parameter] public string? InputId { get; set; }
[Parameter] public string? TestId { get; set; }
private DateOnly? lastValue;
private string displayValue = string.Empty;
private bool isOpen;
private DateOnly visibleMonth;
private IReadOnlyList<CalendarDayCell> calendarDays = [];
protected override void OnParametersSet()
{
if (visibleMonth == default)
{
visibleMonth = ToFirstOfMonth(Value ?? DateOnly.FromDateTime(DateTime.Today));
calendarDays = BuildCalendarDays(visibleMonth);
}
if (Value != lastValue)
{
lastValue = Value;
displayValue = FormatValue(Value);
visibleMonth = ToFirstOfMonth(Value ?? DateOnly.FromDateTime(DateTime.Today));
calendarDays = BuildCalendarDays(visibleMonth);
}
}
private void OpenPopup(FocusEventArgs _)
{
if (Disabled)
{
return;
}
isOpen = true;
visibleMonth = ToFirstOfMonth(Value ?? DateOnly.FromDateTime(DateTime.Today));
calendarDays = BuildCalendarDays(visibleMonth);
}
private void TogglePopup()
{
if (Disabled)
{
return;
}
isOpen = !isOpen;
if (isOpen)
{
visibleMonth = ToFirstOfMonth(Value ?? DateOnly.FromDateTime(DateTime.Today));
calendarDays = BuildCalendarDays(visibleMonth);
}
}
private async Task OnTextChangedAsync(ChangeEventArgs e)
{
displayValue = e.Value?.ToString() ?? string.Empty;
if (TryParseInput(displayValue, out var parsedDate))
{
await SetValueAsync(parsedDate, closePopup: false);
return;
}
if (AllowEmpty && string.IsNullOrWhiteSpace(displayValue))
{
await SetValueAsync(null, closePopup: false);
return;
}
displayValue = FormatValue(Value);
}
private async Task SelectDateAsync(DateOnly date)
{
await SetValueAsync(date, closePopup: true);
}
private async Task ClearAsync()
{
await SetValueAsync(null, closePopup: true);
}
private async Task SetValueAsync(DateOnly? date, bool closePopup)
{
lastValue = date;
Value = date;
displayValue = FormatValue(date);
if (date.HasValue)
{
visibleMonth = ToFirstOfMonth(date.Value);
calendarDays = BuildCalendarDays(visibleMonth);
}
if (closePopup)
{
isOpen = false;
}
await ValueChanged.InvokeAsync(date);
}
private void ShowPreviousMonth()
{
visibleMonth = visibleMonth.AddMonths(-1);
calendarDays = BuildCalendarDays(visibleMonth);
}
private void ShowNextMonth()
{
visibleMonth = visibleMonth.AddMonths(1);
calendarDays = BuildCalendarDays(visibleMonth);
}
private void HandleKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Escape")
{
isOpen = false;
}
}
private string GetInputTestId() => string.IsNullOrWhiteSpace(TestId) ? "localized-date-input" : $"{TestId}-input";
private string GetPopoverTestId() => string.IsNullOrWhiteSpace(TestId) ? "localized-date-popover" : $"{TestId}-popover";
private string GetDayTestId(DateOnly date) => string.IsNullOrWhiteSpace(TestId)
? $"localized-date-day-{date:yyyy-MM-dd}"
: $"{TestId}-day-{date:yyyy-MM-dd}";
private static string FormatValue(DateOnly? date)
{
return date?.ToString("dd/MM/yyyy", ItalianCulture) ?? string.Empty;
}
private static bool TryParseInput(string? value, out DateOnly date)
{
foreach (var format in SupportedFormats)
{
if (DateOnly.TryParseExact(value, format, ItalianCulture, DateTimeStyles.None, out date))
{
return true;
}
}
return DateOnly.TryParse(value, ItalianCulture, DateTimeStyles.None, out date);
}
private static DateOnly ToFirstOfMonth(DateOnly date) => new(date.Year, date.Month, 1);
private static IReadOnlyList<CalendarDayCell> BuildCalendarDays(DateOnly month)
{
var firstDayOfMonth = ToFirstOfMonth(month);
var lastDayOfMonth = firstDayOfMonth.AddMonths(1).AddDays(-1);
var gridStart = firstDayOfMonth.AddDays(-(((int)firstDayOfMonth.DayOfWeek + 6) % 7));
var gridEnd = lastDayOfMonth.AddDays((7 - (((int)lastDayOfMonth.DayOfWeek + 6) % 7) - 1 + 7) % 7);
var days = new List<CalendarDayCell>();
for (var date = gridStart; date <= gridEnd; date = date.AddDays(1))
{
days.Add(new CalendarDayCell(date, date.Month == month.Month && date.Year == month.Year));
}
return days;
}
private static string[] BuildMondayFirstWeekdays()
{
return Enumerable.Range(0, 7)
.Select(index => ItalianCulture.DateTimeFormat.AbbreviatedDayNames[(index + 1) % 7])
.Select(dayName => ItalianCulture.TextInfo.ToTitleCase(dayName))
.ToArray();
}
private sealed record CalendarDayCell(DateOnly Date, bool IsCurrentMonth);
}