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:
parent
0d003903cf
commit
bc28d869eb
14 changed files with 1638 additions and 150 deletions
|
|
@ -1,5 +1,5 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="it-IT">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
264
Components/Shared/CalendarEventEditorModal.razor
Normal file
264
Components/Shared/CalendarEventEditorModal.razor
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
@inject IWorkDayService WorkDayService
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<div class="calendar-modal-backdrop">
|
||||
<div class="calendar-modal-shell calendar-modal-shell-compact">
|
||||
<div class="calendar-modal-dialog" role="dialog" aria-modal="true" aria-labelledby="calendar-event-modal-title" @onclick:stopPropagation="true">
|
||||
<div class="calendar-modal-header">
|
||||
<div>
|
||||
<h2 id="calendar-event-modal-title" class="h5 mb-1">@(isExistingEvent ? "Edit Calendar Event" : "New Calendar Event")</h2>
|
||||
<div class="small text-muted">@FormatDisplayDate(selectedDate)</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseAsync"></button>
|
||||
</div>
|
||||
|
||||
<div class="calendar-modal-body">
|
||||
@if (!loaded)
|
||||
{
|
||||
<p class="mb-0"><em>Loading...</em></p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">Start Date</label>
|
||||
<LocalizedDateInput InputId="calendar-event-modal-start-date" TestId="calendar-event-start-date" Value="@selectedDate" ValueChanged="OnDateChangedAsync" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">End Date</label>
|
||||
<LocalizedDateInput InputId="calendar-event-modal-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">
|
||||
<label class="form-label">Entry Type</label>
|
||||
<select class="form-select" @bind="eventType">
|
||||
@foreach (var item in Enum.GetValues<CalendarEventType>())
|
||||
{
|
||||
<option value="@item">@CalendarEventFormatter.GetEventTypeName(item)</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">Title</label>
|
||||
<input class="form-control" @bind="description" maxlength="120" placeholder="Optional" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">Start Time</label>
|
||||
<input type="time" lang="it-IT" class="form-control" value="@startTimeStr" @onchange="OnStartTimeChanged" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">End Time</label>
|
||||
<input type="time" lang="it-IT" class="form-control" value="@endTimeStr" @onchange="OnEndTimeChanged" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label text-muted">Duration</label>
|
||||
<div class="form-control-plaintext fw-bold">@FormatDuration()</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(statusMessage))
|
||||
{
|
||||
<div class="alert alert-danger py-2 mt-3 mb-0">@statusMessage</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="calendar-modal-actions">
|
||||
<button type="button" class="btn btn-primary" @onclick="SaveAsync" disabled="@(!loaded)">Save</button>
|
||||
@if (isExistingEvent)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-danger" @onclick="DeleteAsync">Delete</button>
|
||||
}
|
||||
<button type="button" class="btn btn-outline-secondary ms-auto" @onclick="CloseAsync">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public DateOnly Date { get; set; }
|
||||
[Parameter] public string? EventId { get; set; }
|
||||
[Parameter] public EventCallback OnSaved { get; set; }
|
||||
[Parameter] public EventCallback OnClosed { get; set; }
|
||||
|
||||
private bool loaded;
|
||||
private bool isExistingEvent;
|
||||
private DateOnly selectedDate;
|
||||
private string eventId = string.Empty;
|
||||
private CalendarEventType eventType = CalendarEventType.Generic;
|
||||
private string description = string.Empty;
|
||||
private string? startTimeStr;
|
||||
private string? endTimeStr;
|
||||
private DateOnly? endDate;
|
||||
private string? statusMessage;
|
||||
private string? loadKey;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
var nextKey = $"{Date:yyyy-MM-dd}|{EventId}";
|
||||
if (nextKey == loadKey)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
loadKey = nextKey;
|
||||
loaded = false;
|
||||
selectedDate = Date;
|
||||
statusMessage = null;
|
||||
await LoadEventAsync();
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
private async Task LoadEventAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(EventId))
|
||||
{
|
||||
SetDefaults();
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = await WorkDayService.GetCalendarEventAsync(selectedDate, EventId);
|
||||
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
|
||||
{
|
||||
SetDefaults();
|
||||
statusMessage = "The selected calendar event was not found. A new event will be created.";
|
||||
}
|
||||
}
|
||||
|
||||
private void SetDefaults()
|
||||
{
|
||||
eventId = string.Empty;
|
||||
eventType = CalendarEventType.Generic;
|
||||
description = string.Empty;
|
||||
startTimeStr = null;
|
||||
endTimeStr = null;
|
||||
endDate = null;
|
||||
isExistingEvent = false;
|
||||
}
|
||||
|
||||
private Task OnDateChangedAsync(DateOnly? value)
|
||||
{
|
||||
if (value.HasValue)
|
||||
{
|
||||
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();
|
||||
statusMessage = null;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task OnEndTimeChanged(ChangeEventArgs e)
|
||||
{
|
||||
endTimeStr = e.Value?.ToString();
|
||||
statusMessage = null;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
var calendarEvent = new CalendarEventDocument
|
||||
{
|
||||
Id = eventId,
|
||||
StartDate = selectedDate,
|
||||
EndDate = endDate,
|
||||
EventType = eventType,
|
||||
Description = description,
|
||||
StartTime = ParseTime(startTimeStr),
|
||||
EndTime = ParseTime(endTimeStr)
|
||||
};
|
||||
|
||||
await WorkDayService.SaveCalendarEventAsync(Date, calendarEvent);
|
||||
await OnSaved.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (!isExistingEvent || string.IsNullOrWhiteSpace(eventId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
var deleted = await WorkDayService.DeleteCalendarEventAsync(Date, eventId);
|
||||
if (deleted)
|
||||
{
|
||||
await OnSaved.InvokeAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
statusMessage = "Unable to delete the calendar event.";
|
||||
}
|
||||
|
||||
private Task CloseAsync() => OnClosed.InvokeAsync();
|
||||
|
||||
private decimal? GetDuration()
|
||||
{
|
||||
var start = ParseTime(startTimeStr);
|
||||
var end = ParseTime(endTimeStr);
|
||||
if (!start.HasValue || !end.HasValue || end <= start)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.Round((decimal)(end.Value - start.Value).TotalHours, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private string FormatDuration() => GetDuration() is { } duration ? FormatDurationHours(duration) : "—";
|
||||
|
||||
private static string FormatDurationHours(decimal value)
|
||||
{
|
||||
var totalMinutes = (int)Math.Round(value * 60m, MidpointRounding.AwayFromZero);
|
||||
var hours = totalMinutes / 60;
|
||||
var minutes = totalMinutes % 60;
|
||||
return $"{hours:00}:{minutes:00}";
|
||||
}
|
||||
|
||||
private static TimeOnly? ParseTime(string? value)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(value) && TimeOnly.TryParse(value, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string FormatDisplayDate(DateOnly date)
|
||||
{
|
||||
return date.ToString("dddd dd/MM/yyyy");
|
||||
}
|
||||
}
|
||||
246
Components/Shared/LocalizedDateInput.razor
Normal file
246
Components/Shared/LocalizedDateInput.razor
Normal 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">‹</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">›</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);
|
||||
}
|
||||
411
Components/Shared/WorkUnitEditorModal.razor
Normal file
411
Components/Shared/WorkUnitEditorModal.razor
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
@inject IWorkDayService WorkDayService
|
||||
@inject IAppSettingsService AppSettingsService
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<div class="calendar-modal-backdrop">
|
||||
<div class="calendar-modal-shell">
|
||||
<div class="calendar-modal-dialog" role="dialog" aria-modal="true" aria-labelledby="work-unit-modal-title" @onclick:stopPropagation="true">
|
||||
<div class="calendar-modal-header">
|
||||
<div>
|
||||
<h2 id="work-unit-modal-title" class="h5 mb-1">@(isExistingUnit ? "Edit Work Unit" : "New Work Unit")</h2>
|
||||
<div class="small text-muted">@FormatDisplayDate(selectedDate)</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseAsync"></button>
|
||||
</div>
|
||||
|
||||
<div class="calendar-modal-body">
|
||||
@if (!loaded)
|
||||
{
|
||||
<p class="mb-0"><em>Loading...</em></p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<label class="form-label">Date</label>
|
||||
<LocalizedDateInput InputId="work-unit-modal-date" TestId="work-unit-modal-date" Value="@selectedDate" ValueChanged="OnDateChangedAsync" Disabled="@isExistingUnit" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<label class="form-label">Label</label>
|
||||
<input class="form-control" @bind="label" maxlength="40" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<label class="form-label">Location</label>
|
||||
<select class="form-select" @bind="location">
|
||||
@foreach (var item in Enum.GetValues<WorkUnitLocation>())
|
||||
{
|
||||
<option value="@item">@item</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<label class="form-label">Start Time</label>
|
||||
<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" lang="it-IT" class="form-control" value="@endTimeStr" @onchange="OnEndTimeChanged" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<label class="form-label">Counted Hours</label>
|
||||
<input type="text" class="form-control" value="@manualWorkedHoursStr" @onchange="OnManualWorkedHoursChanged" placeholder="00:00" inputmode="numeric" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 col-lg-4 d-flex align-items-end">
|
||||
<div class="form-check mb-2">
|
||||
<input id="preview-checkbox-modal" type="checkbox" class="form-check-input" @bind="isPreview" />
|
||||
<label class="form-check-label" for="preview-checkbox-modal">Preview work unit</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">Notes</label>
|
||||
<textarea class="form-control" rows="2" @bind="notes"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<h3 class="h6">Computed values</h3>
|
||||
<div class="row g-3">
|
||||
<div class="col-6 col-md-4 col-lg-3">
|
||||
<label class="form-label text-muted">Calculated Hours</label>
|
||||
<div class="form-control-plaintext fw-bold">@FormatHours(calculatedWorkedHours)</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-lg-3">
|
||||
<label class="form-label text-muted">Difference</label>
|
||||
<div class="form-control-plaintext fw-bold">@FormatSignedHours(workedHoursDelta)</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-lg-3">
|
||||
<label class="form-label text-muted">Gross Income</label>
|
||||
<div class="form-control-plaintext fw-bold">€@grossIncome.ToString("N2")</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-lg-3">
|
||||
<label class="form-label text-muted">Net Income</label>
|
||||
<div class="form-control-plaintext fw-bold">€@netIncome.ToString("N2")</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<h3 class="h6">Day Total</h3>
|
||||
<div class="row g-3">
|
||||
<div class="col-6 col-md-4 col-lg-3">
|
||||
<label class="form-label text-muted">Total Hours For Day</label>
|
||||
<div class="form-control-plaintext fw-bold">@FormatHours(dayTotalHours)</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-lg-3">
|
||||
<label class="form-label text-muted">Work Units Counted</label>
|
||||
<div class="form-control-plaintext fw-bold">@dayWorkUnitCount</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(statusMessage))
|
||||
{
|
||||
<div class="alert alert-danger py-2 mt-3 mb-0">@statusMessage</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="calendar-modal-actions">
|
||||
<button type="button" class="btn btn-primary" @onclick="SaveAsync" disabled="@(!loaded)">Save</button>
|
||||
@if (isExistingUnit)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-danger" @onclick="DeleteAsync">Delete</button>
|
||||
}
|
||||
<button type="button" class="btn btn-outline-secondary ms-auto" @onclick="CloseAsync">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public DateOnly Date { get; set; }
|
||||
[Parameter] public string? UnitId { get; set; }
|
||||
[Parameter] public EventCallback OnSaved { get; set; }
|
||||
[Parameter] public EventCallback OnClosed { get; set; }
|
||||
|
||||
private bool loaded;
|
||||
private DateOnly selectedDate;
|
||||
private string unitId = string.Empty;
|
||||
private string label = "Work unit";
|
||||
private WorkUnitLocation location = WorkUnitLocation.Office;
|
||||
private string? startTimeStr;
|
||||
private string? endTimeStr;
|
||||
private decimal manualWorkedHours;
|
||||
private string manualWorkedHoursStr = "00:00";
|
||||
private bool isPreview;
|
||||
private string? notes;
|
||||
private string? statusMessage;
|
||||
private bool isExistingUnit;
|
||||
private WorkDayDocument? selectedDay;
|
||||
private string? loadKey;
|
||||
|
||||
private decimal grossIncome;
|
||||
private decimal netIncome;
|
||||
private decimal? calculatedWorkedHours;
|
||||
private decimal workedHoursDelta;
|
||||
private decimal dayTotalHours;
|
||||
private int dayWorkUnitCount;
|
||||
|
||||
private AppSettingsDocument settings = new();
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
var nextKey = $"{Date:yyyy-MM-dd}|{UnitId}";
|
||||
if (nextKey == loadKey)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
loadKey = nextKey;
|
||||
loaded = false;
|
||||
selectedDate = Date;
|
||||
statusMessage = null;
|
||||
settings = await AppSettingsService.GetAsync();
|
||||
await LoadUnitAsync();
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
private async Task LoadUnitAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(UnitId))
|
||||
{
|
||||
selectedDay = await WorkDayService.GetAsync(selectedDate);
|
||||
SetDefaults();
|
||||
return;
|
||||
}
|
||||
|
||||
selectedDay = await WorkDayService.GetAsync(selectedDate);
|
||||
var existing = await WorkDayService.GetWorkUnitAsync(selectedDate, UnitId);
|
||||
if (existing is not null)
|
||||
{
|
||||
unitId = existing.Id;
|
||||
label = existing.Label;
|
||||
location = existing.Location;
|
||||
startTimeStr = existing.StartTime?.ToString("HH:mm");
|
||||
endTimeStr = existing.EndTime?.ToString("HH:mm");
|
||||
manualWorkedHours = existing.ManualWorkedHours;
|
||||
manualWorkedHoursStr = FormatDurationHours(existing.ManualWorkedHours);
|
||||
isPreview = existing.IsPreview;
|
||||
notes = existing.Notes;
|
||||
isExistingUnit = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
SetDefaults();
|
||||
statusMessage = "The selected work unit was not found. A new unit will be created for this day.";
|
||||
}
|
||||
|
||||
RecomputePreview();
|
||||
}
|
||||
|
||||
private async Task OnDateChangedAsync(DateOnly? value)
|
||||
{
|
||||
if (value.HasValue)
|
||||
{
|
||||
selectedDate = value.Value;
|
||||
selectedDay = await WorkDayService.GetAsync(selectedDate);
|
||||
statusMessage = null;
|
||||
RecomputePreview();
|
||||
}
|
||||
}
|
||||
|
||||
private Task OnStartTimeChanged(ChangeEventArgs e)
|
||||
{
|
||||
startTimeStr = e.Value?.ToString();
|
||||
SyncManualHoursToCalculated();
|
||||
statusMessage = null;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task OnEndTimeChanged(ChangeEventArgs e)
|
||||
{
|
||||
endTimeStr = e.Value?.ToString();
|
||||
SyncManualHoursToCalculated();
|
||||
statusMessage = null;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task OnManualWorkedHoursChanged(ChangeEventArgs e)
|
||||
{
|
||||
var rawValue = e.Value?.ToString();
|
||||
if (TryParseDurationHours(rawValue, out var parsedHours))
|
||||
{
|
||||
manualWorkedHours = parsedHours;
|
||||
manualWorkedHoursStr = FormatDurationHours(parsedHours);
|
||||
}
|
||||
else
|
||||
{
|
||||
manualWorkedHoursStr = FormatDurationHours(manualWorkedHours);
|
||||
}
|
||||
|
||||
RecomputePreview();
|
||||
statusMessage = null;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void SetDefaults()
|
||||
{
|
||||
unitId = string.Empty;
|
||||
label = "Work unit";
|
||||
location = WorkUnitLocation.Office;
|
||||
startTimeStr = null;
|
||||
endTimeStr = null;
|
||||
manualWorkedHours = 0m;
|
||||
manualWorkedHoursStr = "00:00";
|
||||
isPreview = false;
|
||||
notes = null;
|
||||
isExistingUnit = false;
|
||||
RecomputePreview();
|
||||
}
|
||||
|
||||
private void SyncManualHoursToCalculated()
|
||||
{
|
||||
var calculated = CalculateDuration(ParseTime(startTimeStr), ParseTime(endTimeStr));
|
||||
manualWorkedHours = calculated ?? 0m;
|
||||
manualWorkedHoursStr = FormatDurationHours(manualWorkedHours);
|
||||
RecomputePreview();
|
||||
}
|
||||
|
||||
private void RecomputePreview()
|
||||
{
|
||||
calculatedWorkedHours = CalculateDuration(ParseTime(startTimeStr), ParseTime(endTimeStr));
|
||||
workedHoursDelta = manualWorkedHours - (calculatedWorkedHours ?? 0m);
|
||||
grossIncome = manualWorkedHours * settings.HourlyGrossRate;
|
||||
var taxableBase = grossIncome * settings.ProfitabilityCoefficient;
|
||||
netIncome = grossIncome - (taxableBase * settings.InpsRate) - (taxableBase * settings.SubstituteTaxRate);
|
||||
RecomputeDayTotals();
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
RecomputePreview();
|
||||
|
||||
var workUnit = new WorkUnitDocument
|
||||
{
|
||||
Id = unitId,
|
||||
Label = label,
|
||||
Location = location,
|
||||
StartTime = ParseTime(startTimeStr),
|
||||
EndTime = ParseTime(endTimeStr),
|
||||
ManualWorkedHours = Math.Max(0m, manualWorkedHours),
|
||||
IsPreview = isPreview,
|
||||
Notes = notes
|
||||
};
|
||||
|
||||
await WorkDayService.SaveWorkUnitAsync(selectedDate, workUnit);
|
||||
await OnSaved.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (!isExistingUnit || string.IsNullOrWhiteSpace(unitId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var confirmed = await JS.InvokeAsync<bool>("confirm", $"Delete work unit '{label}' on {FormatDisplayDate(selectedDate)}?\nThis cannot be undone.");
|
||||
if (!confirmed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var deleted = await WorkDayService.DeleteWorkUnitAsync(selectedDate, unitId);
|
||||
if (deleted)
|
||||
{
|
||||
await OnSaved.InvokeAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
statusMessage = "Unable to delete the work unit.";
|
||||
}
|
||||
|
||||
private Task CloseAsync() => OnClosed.InvokeAsync();
|
||||
|
||||
private void RecomputeDayTotals()
|
||||
{
|
||||
var existingUnits = selectedDay?.WorkUnits ?? [];
|
||||
dayTotalHours = existingUnits
|
||||
.Where(unit => !string.Equals(unit.Id, unitId, StringComparison.Ordinal))
|
||||
.Sum(unit => unit.ManualWorkedHours) + manualWorkedHours;
|
||||
|
||||
dayWorkUnitCount = existingUnits
|
||||
.Where(unit => !string.Equals(unit.Id, unitId, StringComparison.Ordinal))
|
||||
.Count() + 1;
|
||||
}
|
||||
|
||||
private static TimeOnly? ParseTime(string? value)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(value) && TimeOnly.TryParse(value, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
private static decimal? CalculateDuration(TimeOnly? startTime, TimeOnly? endTime)
|
||||
{
|
||||
if (!startTime.HasValue || !endTime.HasValue || endTime <= startTime)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.Round((decimal)(endTime.Value - startTime.Value).TotalHours, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private static string FormatDurationHours(decimal value)
|
||||
{
|
||||
var totalMinutes = (int)Math.Round(value * 60m, MidpointRounding.AwayFromZero);
|
||||
var hours = totalMinutes / 60;
|
||||
var minutes = totalMinutes % 60;
|
||||
return $"{hours:00}:{minutes:00}";
|
||||
}
|
||||
|
||||
private static string FormatHours(decimal? value)
|
||||
{
|
||||
return value.HasValue ? DurationFormatter.FormatHours(value.Value) : "—";
|
||||
}
|
||||
|
||||
private static string FormatSignedHours(decimal value)
|
||||
{
|
||||
if (value == 0m)
|
||||
{
|
||||
return "00:00";
|
||||
}
|
||||
|
||||
var prefix = value > 0m ? "+" : "-";
|
||||
return prefix + DurationFormatter.FormatHours(Math.Abs(value));
|
||||
}
|
||||
|
||||
private static bool TryParseDurationHours(string? value, out decimal hours)
|
||||
{
|
||||
hours = 0m;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TimeOnly.TryParse(value, out var parsedTime))
|
||||
{
|
||||
hours = parsedTime.Hour + (parsedTime.Minute / 60m);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (decimal.TryParse(value, out var parsedDecimal))
|
||||
{
|
||||
hours = Math.Max(0m, parsedDecimal);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string FormatDisplayDate(DateOnly date)
|
||||
{
|
||||
return date.ToString("dddd dd/MM/yyyy");
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
@using Microsoft.JSInterop
|
||||
@using WorkTracker
|
||||
@using WorkTracker.Components
|
||||
@using WorkTracker.Components.Shared
|
||||
@using WorkTracker.Domain
|
||||
@using WorkTracker.Formatting
|
||||
@using WorkTracker.Services.Festivities
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ public sealed class CalendarEventDocument
|
|||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
public DateOnly StartDate { get; set; }
|
||||
|
||||
public DateOnly? EndDate { get; set; }
|
||||
|
||||
public CalendarEventType EventType { get; set; } = CalendarEventType.Generic;
|
||||
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
|
|
|||
27
Formatting/CalendarEventFormatter.cs
Normal file
27
Formatting/CalendarEventFormatter.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
using WorkTracker.Domain;
|
||||
|
||||
namespace WorkTracker.Formatting;
|
||||
|
||||
public static class CalendarEventFormatter
|
||||
{
|
||||
public static string GetDisplayDescription(CalendarEventDocument calendarEvent)
|
||||
{
|
||||
return GetDisplayDescription(calendarEvent.EventType, calendarEvent.Description);
|
||||
}
|
||||
|
||||
public static string GetDisplayDescription(CalendarEventType eventType, string? description)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(description)
|
||||
? GetEventTypeName(eventType)
|
||||
: description.Trim();
|
||||
}
|
||||
|
||||
public static string GetEventTypeName(CalendarEventType eventType)
|
||||
{
|
||||
return eventType switch
|
||||
{
|
||||
CalendarEventType.DayOff => "Day Off",
|
||||
_ => eventType.ToString()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
using Couchbase.Lite;
|
||||
using Couchbase.Lite.Query;
|
||||
using WorkTracker.Domain;
|
||||
using WorkTracker.Formatting;
|
||||
using WorkTracker.Services.Festivities;
|
||||
using WorkTracker.Services.Settings;
|
||||
using WorkTracker.Services.Storage;
|
||||
|
|
@ -44,7 +46,13 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
|||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var day = await GetAsync(date, cancellationToken);
|
||||
return day?.CalendarEvents.FirstOrDefault(calendarEvent => string.Equals(calendarEvent.Id, calendarEventId, StringComparison.Ordinal));
|
||||
var calendarEvent = day?.CalendarEvents.FirstOrDefault(entry => string.Equals(entry.Id, calendarEventId, StringComparison.Ordinal));
|
||||
if (calendarEvent is not null)
|
||||
{
|
||||
return calendarEvent;
|
||||
}
|
||||
|
||||
return FindCalendarEventLocation(calendarEventId, cancellationToken)?.CalendarEvent;
|
||||
}
|
||||
|
||||
public async Task<WorkUnitDocument> SaveWorkUnitAsync(DateOnly date, WorkUnitDocument workUnit, CancellationToken cancellationToken = default)
|
||||
|
|
@ -92,32 +100,59 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
|||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var day = await GetOrCreateDayAsync(date, cancellationToken);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var existingIndex = day.CalendarEvents.FindIndex(entry => string.Equals(entry.Id, calendarEvent.Id, StringComparison.Ordinal));
|
||||
var existingCreatedAt = existingIndex >= 0 ? day.CalendarEvents[existingIndex].CreatedAtUtc : now;
|
||||
var existingLocation = string.IsNullOrWhiteSpace(calendarEvent.Id)
|
||||
? null
|
||||
: FindCalendarEventLocation(calendarEvent.Id, cancellationToken);
|
||||
var targetDate = calendarEvent.StartDate == default ? date : calendarEvent.StartDate;
|
||||
var endDate = calendarEvent.EndDate;
|
||||
|
||||
if (endDate.HasValue && endDate.Value < targetDate)
|
||||
{
|
||||
(targetDate, endDate) = (endDate.Value, targetDate);
|
||||
}
|
||||
|
||||
calendarEvent.Id = string.IsNullOrWhiteSpace(calendarEvent.Id) ? Guid.NewGuid().ToString("N") : calendarEvent.Id;
|
||||
calendarEvent.Description = string.IsNullOrWhiteSpace(calendarEvent.Description)
|
||||
? "Calendar entry"
|
||||
: calendarEvent.Description.Trim();
|
||||
calendarEvent.CreatedAtUtc = existingCreatedAt;
|
||||
calendarEvent.StartDate = targetDate;
|
||||
calendarEvent.EndDate = endDate.HasValue && endDate.Value > targetDate
|
||||
? endDate.Value
|
||||
: null;
|
||||
calendarEvent.Description = calendarEvent.Description?.Trim() ?? string.Empty;
|
||||
calendarEvent.CreatedAtUtc = existingLocation?.CalendarEvent.CreatedAtUtc ?? now;
|
||||
calendarEvent.UpdatedAtUtc = now;
|
||||
|
||||
Compute(calendarEvent);
|
||||
|
||||
if (existingIndex >= 0)
|
||||
if (existingLocation is not null)
|
||||
{
|
||||
day.CalendarEvents[existingIndex] = calendarEvent;
|
||||
var ownerDay = existingLocation.Day;
|
||||
if (ownerDay.Date == targetDate)
|
||||
{
|
||||
ownerDay.CalendarEvents[existingLocation.Index] = calendarEvent;
|
||||
ownerDay.UpdatedAtUtc = now;
|
||||
SortEntries(ownerDay);
|
||||
SaveDocument(ownerDay);
|
||||
return calendarEvent;
|
||||
}
|
||||
|
||||
ownerDay.CalendarEvents.RemoveAt(existingLocation.Index);
|
||||
DeleteOrSaveDay(ownerDay);
|
||||
}
|
||||
|
||||
var targetDay = await GetOrCreateDayAsync(targetDate, cancellationToken);
|
||||
var targetIndex = targetDay.CalendarEvents.FindIndex(entry => string.Equals(entry.Id, calendarEvent.Id, StringComparison.Ordinal));
|
||||
if (targetIndex >= 0)
|
||||
{
|
||||
targetDay.CalendarEvents[targetIndex] = calendarEvent;
|
||||
}
|
||||
else
|
||||
{
|
||||
day.CalendarEvents.Add(calendarEvent);
|
||||
targetDay.CalendarEvents.Add(calendarEvent);
|
||||
}
|
||||
|
||||
day.UpdatedAtUtc = now;
|
||||
SortEntries(day);
|
||||
SaveDocument(day);
|
||||
targetDay.UpdatedAtUtc = now;
|
||||
SortEntries(targetDay);
|
||||
SaveDocument(targetDay);
|
||||
return calendarEvent;
|
||||
}
|
||||
|
||||
|
|
@ -145,36 +180,79 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
|||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var day = await GetAsync(date, cancellationToken);
|
||||
if (day is null)
|
||||
if (day is not null)
|
||||
{
|
||||
var removed = day.CalendarEvents.RemoveAll(calendarEvent => string.Equals(calendarEvent.Id, calendarEventId, StringComparison.Ordinal));
|
||||
if (removed > 0)
|
||||
{
|
||||
return DeleteOrSaveDay(day);
|
||||
}
|
||||
}
|
||||
|
||||
var existingLocation = FindCalendarEventLocation(calendarEventId, cancellationToken);
|
||||
if (existingLocation is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var removed = day.CalendarEvents.RemoveAll(calendarEvent => string.Equals(calendarEvent.Id, calendarEventId, StringComparison.Ordinal));
|
||||
if (removed == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return DeleteOrSaveDay(day);
|
||||
existingLocation.Day.CalendarEvents.RemoveAt(existingLocation.Index);
|
||||
return DeleteOrSaveDay(existingLocation.Day);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<WorkDayDocument>> GetRangeAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var results = new List<WorkDayDocument>();
|
||||
for (var date = from; date <= to; date = date.AddDays(1))
|
||||
var storedDays = GetAllDays(cancellationToken);
|
||||
var results = new Dictionary<DateOnly, WorkDayDocument>();
|
||||
|
||||
foreach (var day in storedDays)
|
||||
{
|
||||
var id = date.ToString("yyyy-MM-dd");
|
||||
var doc = workDaysCollection.GetDocument(id);
|
||||
if (doc is not null)
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (day.Date >= from && day.Date <= to)
|
||||
{
|
||||
results.Add(Map(doc));
|
||||
if (results.TryGetValue(day.Date, out var existingRangeDay))
|
||||
{
|
||||
MergeStoredDayIntoRangeDay(existingRangeDay, day);
|
||||
}
|
||||
else
|
||||
{
|
||||
results[day.Date] = CloneDayForRange(day.Date, day);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var calendarEvent in day.CalendarEvents)
|
||||
{
|
||||
var startDate = calendarEvent.StartDate == default ? day.Date : calendarEvent.StartDate;
|
||||
var endDate = calendarEvent.EndDate ?? startDate;
|
||||
if (endDate < from || startDate > to)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var overlapStart = startDate < from ? from : startDate;
|
||||
var overlapEnd = endDate > to ? to : endDate;
|
||||
for (var date = overlapStart; date <= overlapEnd; date = date.AddDays(1))
|
||||
{
|
||||
if (!results.TryGetValue(date, out var rangeDay))
|
||||
{
|
||||
rangeDay = CloneDayForRange(date);
|
||||
results.Add(date, rangeDay);
|
||||
}
|
||||
|
||||
AddProjectedCalendarEvent(rangeDay, calendarEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<WorkDayDocument>>(results);
|
||||
var orderedResults = results.Values.OrderBy(day => day.Date).ToList();
|
||||
foreach (var day in orderedResults)
|
||||
{
|
||||
SortEntries(day);
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<WorkDayDocument>>(orderedResults);
|
||||
}
|
||||
|
||||
public async Task<MonthlySummaryModel> GetMonthlySummaryAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default)
|
||||
|
|
@ -254,6 +332,8 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
|||
var from = new DateOnly(year, month, 1);
|
||||
var to = from.AddMonths(1).AddDays(-1);
|
||||
var createdDays = 0;
|
||||
var projectedDays = await GetRangeAsync(from, to, cancellationToken);
|
||||
var projectedLookup = projectedDays.ToDictionary(day => day.Date);
|
||||
|
||||
for (var date = from; date <= to; date = date.AddDays(1))
|
||||
{
|
||||
|
|
@ -265,7 +345,8 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
|||
}
|
||||
|
||||
var day = await GetOrCreateDayAsync(date, cancellationToken);
|
||||
if (day.WorkUnits.Count > 0 || day.CalendarEvents.Any(entry => IsNonWorkingEvent(entry.EventType)))
|
||||
var projectedDay = projectedLookup.GetValueOrDefault(date);
|
||||
if (day.WorkUnits.Count > 0 || projectedDay?.CalendarEvents.Any(entry => IsNonWorkingEvent(entry.EventType)) == true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
|
@ -320,10 +401,15 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
|||
{
|
||||
var entry = new MutableDictionaryObject();
|
||||
entry.SetString("id", calendarEvent.Id);
|
||||
entry.SetString("startDate", (calendarEvent.StartDate == default ? day.Date : calendarEvent.StartDate).ToString("yyyy-MM-dd"));
|
||||
entry.SetInt("eventType", (int)calendarEvent.EventType);
|
||||
entry.SetString("description", calendarEvent.Description);
|
||||
entry.SetString("startTime", calendarEvent.StartTime?.ToString("HH:mm"));
|
||||
entry.SetString("endTime", calendarEvent.EndTime?.ToString("HH:mm"));
|
||||
if (calendarEvent.EndDate.HasValue)
|
||||
{
|
||||
entry.SetString("endDate", calendarEvent.EndDate.Value.ToString("yyyy-MM-dd"));
|
||||
}
|
||||
if (calendarEvent.DurationHours.HasValue)
|
||||
{
|
||||
entry.SetDouble("durationHours", decimal.ToDouble(calendarEvent.DurationHours.Value));
|
||||
|
|
@ -364,6 +450,8 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
|||
|
||||
private static WorkDayDocument Map(Document doc)
|
||||
{
|
||||
var date = DateOnly.ParseExact(doc.GetString("date") ?? doc.Id, "yyyy-MM-dd");
|
||||
|
||||
if (!doc.Contains("workUnits") && !doc.Contains("calendarEvents"))
|
||||
{
|
||||
return MapLegacy(doc);
|
||||
|
|
@ -392,7 +480,7 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
|||
var calendarEvent = calendarEventsArray.GetDictionary(i);
|
||||
if (calendarEvent is not null)
|
||||
{
|
||||
calendarEvents.Add(MapCalendarEvent(calendarEvent));
|
||||
calendarEvents.Add(MapCalendarEvent(calendarEvent, date));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -400,7 +488,7 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
|||
return new WorkDayDocument
|
||||
{
|
||||
Id = doc.Id,
|
||||
Date = DateOnly.ParseExact(doc.GetString("date") ?? doc.Id, "yyyy-MM-dd"),
|
||||
Date = date,
|
||||
IsWeekend = doc.GetBoolean("isWeekend"),
|
||||
IsItalianFestivity = doc.GetBoolean("isItalianFestivity"),
|
||||
WorkUnits = workUnits,
|
||||
|
|
@ -593,12 +681,13 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
|||
|
||||
private static string FormatTimesheetEventSummary(CalendarEventDocument calendarEvent)
|
||||
{
|
||||
var description = CalendarEventFormatter.GetDisplayDescription(calendarEvent);
|
||||
if (calendarEvent.StartTime.HasValue)
|
||||
{
|
||||
return $"{calendarEvent.EventType}: {calendarEvent.Description} ({calendarEvent.StartTime:HH:mm})";
|
||||
return $"{CalendarEventFormatter.GetEventTypeName(calendarEvent.EventType)}: {description} ({calendarEvent.StartTime:HH:mm})";
|
||||
}
|
||||
|
||||
return $"{calendarEvent.EventType}: {calendarEvent.Description}";
|
||||
return $"{CalendarEventFormatter.GetEventTypeName(calendarEvent.EventType)}: {description}";
|
||||
}
|
||||
|
||||
private static string FormatCompactHours(decimal value)
|
||||
|
|
@ -701,13 +790,15 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
|||
return workUnit;
|
||||
}
|
||||
|
||||
private static CalendarEventDocument MapCalendarEvent(DictionaryObject calendarEvent)
|
||||
private static CalendarEventDocument MapCalendarEvent(DictionaryObject calendarEvent, DateOnly owningDate)
|
||||
{
|
||||
var entry = new CalendarEventDocument
|
||||
{
|
||||
Id = calendarEvent.GetString("id") ?? Guid.NewGuid().ToString("N"),
|
||||
StartDate = ReadDateOnly(calendarEvent, "startDate") ?? owningDate,
|
||||
EndDate = ReadDateOnly(calendarEvent, "endDate"),
|
||||
EventType = calendarEvent.Contains("eventType") ? (CalendarEventType)calendarEvent.GetInt("eventType") : CalendarEventType.Generic,
|
||||
Description = calendarEvent.GetString("description") ?? "Calendar entry",
|
||||
Description = calendarEvent.GetString("description") ?? string.Empty,
|
||||
StartTime = ReadTimeOnly(calendarEvent, "startTime"),
|
||||
EndTime = ReadTimeOnly(calendarEvent, "endTime"),
|
||||
DurationHours = calendarEvent.Contains("durationHours") ? ReadDecimal(calendarEvent, "durationHours", 0m) : null,
|
||||
|
|
@ -769,6 +860,7 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
|||
var calendarEvent = new CalendarEventDocument
|
||||
{
|
||||
Id = "legacy",
|
||||
StartDate = date,
|
||||
EventType = MapLegacyEventType(dayType),
|
||||
Description = string.IsNullOrWhiteSpace(doc.GetString("notes")) ? $"Legacy {dayType}" : doc.GetString("notes")!,
|
||||
CreatedAtUtc = day.CreatedAtUtc,
|
||||
|
|
@ -810,6 +902,14 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
|||
: null;
|
||||
}
|
||||
|
||||
private static DateOnly? ReadDateOnly(DictionaryObject doc, string key)
|
||||
{
|
||||
var value = doc.GetString(key);
|
||||
return !string.IsNullOrEmpty(value) && DateOnly.TryParseExact(value, "yyyy-MM-dd", out var date)
|
||||
? date
|
||||
: null;
|
||||
}
|
||||
|
||||
private static decimal ReadDecimal(Document doc, string key, decimal defaultValue)
|
||||
{
|
||||
return doc.Contains(key)
|
||||
|
|
@ -824,6 +924,148 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
|||
: defaultValue;
|
||||
}
|
||||
|
||||
private IReadOnlyList<WorkDayDocument> GetAllDays(CancellationToken cancellationToken)
|
||||
{
|
||||
var query = QueryBuilder
|
||||
.Select(SelectResult.Expression(Meta.ID))
|
||||
.From(DataSource.Collection(workDaysCollection))
|
||||
.OrderBy(Ordering.Expression(Meta.ID));
|
||||
|
||||
var results = new List<WorkDayDocument>();
|
||||
foreach (var result in query.Execute())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var id = result.GetString(0);
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var doc = workDaysCollection.GetDocument(id);
|
||||
if (doc is not null)
|
||||
{
|
||||
results.Add(Map(doc));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private WorkDayDocument CloneDayForRange(DateOnly date, WorkDayDocument? sourceDay = null)
|
||||
{
|
||||
return new WorkDayDocument
|
||||
{
|
||||
Id = date.ToString("yyyy-MM-dd"),
|
||||
Date = date,
|
||||
IsWeekend = date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday,
|
||||
IsItalianFestivity = festivitySource.GetFestivities(date.Year).Contains(date),
|
||||
WorkUnits = sourceDay?.WorkUnits.Select(CloneWorkUnit).ToList() ?? [],
|
||||
CalendarEvents = [],
|
||||
CreatedAtUtc = sourceDay?.CreatedAtUtc ?? DateTimeOffset.UtcNow,
|
||||
UpdatedAtUtc = sourceDay?.UpdatedAtUtc ?? DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static WorkUnitDocument CloneWorkUnit(WorkUnitDocument workUnit)
|
||||
{
|
||||
return new WorkUnitDocument
|
||||
{
|
||||
Id = workUnit.Id,
|
||||
Label = workUnit.Label,
|
||||
Location = workUnit.Location,
|
||||
StartTime = workUnit.StartTime,
|
||||
EndTime = workUnit.EndTime,
|
||||
IsPreview = workUnit.IsPreview,
|
||||
ManualWorkedHours = workUnit.ManualWorkedHours,
|
||||
CalculatedWorkedHours = workUnit.CalculatedWorkedHours,
|
||||
WorkedHoursDelta = workUnit.WorkedHoursDelta,
|
||||
GrossIncome = workUnit.GrossIncome,
|
||||
NetIncome = workUnit.NetIncome,
|
||||
Notes = workUnit.Notes,
|
||||
CoeffSnapshot = new CoeffSnapshotDocument
|
||||
{
|
||||
StandardWorkHoursPerDay = workUnit.CoeffSnapshot.StandardWorkHoursPerDay,
|
||||
HourlyGrossRate = workUnit.CoeffSnapshot.HourlyGrossRate,
|
||||
ProfitabilityCoefficient = workUnit.CoeffSnapshot.ProfitabilityCoefficient,
|
||||
InpsRate = workUnit.CoeffSnapshot.InpsRate,
|
||||
SubstituteTaxRate = workUnit.CoeffSnapshot.SubstituteTaxRate
|
||||
},
|
||||
CreatedAtUtc = workUnit.CreatedAtUtc,
|
||||
UpdatedAtUtc = workUnit.UpdatedAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
private static CalendarEventDocument CloneCalendarEvent(CalendarEventDocument calendarEvent)
|
||||
{
|
||||
return new CalendarEventDocument
|
||||
{
|
||||
Id = calendarEvent.Id,
|
||||
StartDate = calendarEvent.StartDate,
|
||||
EndDate = calendarEvent.EndDate,
|
||||
EventType = calendarEvent.EventType,
|
||||
Description = calendarEvent.Description,
|
||||
StartTime = calendarEvent.StartTime,
|
||||
EndTime = calendarEvent.EndTime,
|
||||
DurationHours = calendarEvent.DurationHours,
|
||||
CreatedAtUtc = calendarEvent.CreatedAtUtc,
|
||||
UpdatedAtUtc = calendarEvent.UpdatedAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
private static void AddProjectedCalendarEvent(WorkDayDocument rangeDay, CalendarEventDocument calendarEvent)
|
||||
{
|
||||
if (rangeDay.CalendarEvents.Any(existing => string.Equals(existing.Id, calendarEvent.Id, StringComparison.Ordinal)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
rangeDay.CalendarEvents.Add(CloneCalendarEvent(calendarEvent));
|
||||
}
|
||||
|
||||
private static void MergeStoredDayIntoRangeDay(WorkDayDocument rangeDay, WorkDayDocument storedDay)
|
||||
{
|
||||
rangeDay.IsWeekend = storedDay.IsWeekend;
|
||||
rangeDay.IsItalianFestivity = storedDay.IsItalianFestivity;
|
||||
rangeDay.CreatedAtUtc = storedDay.CreatedAtUtc;
|
||||
rangeDay.UpdatedAtUtc = storedDay.UpdatedAtUtc;
|
||||
rangeDay.WorkUnits = storedDay.WorkUnits.Select(CloneWorkUnit).ToList();
|
||||
|
||||
foreach (var calendarEvent in storedDay.CalendarEvents)
|
||||
{
|
||||
var existingIndex = rangeDay.CalendarEvents.FindIndex(existing => string.Equals(existing.Id, calendarEvent.Id, StringComparison.Ordinal));
|
||||
if (existingIndex >= 0)
|
||||
{
|
||||
rangeDay.CalendarEvents[existingIndex] = CloneCalendarEvent(calendarEvent);
|
||||
}
|
||||
else
|
||||
{
|
||||
rangeDay.CalendarEvents.Add(CloneCalendarEvent(calendarEvent));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private CalendarEventLocation? FindCalendarEventLocation(string calendarEventId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(calendarEventId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var day in GetAllDays(cancellationToken))
|
||||
{
|
||||
var index = day.CalendarEvents.FindIndex(calendarEvent => string.Equals(calendarEvent.Id, calendarEventId, StringComparison.Ordinal));
|
||||
if (index >= 0)
|
||||
{
|
||||
return new CalendarEventLocation(day, index, day.CalendarEvents[index]);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private sealed record CalendarEventLocation(WorkDayDocument Day, int Index, CalendarEventDocument CalendarEvent);
|
||||
|
||||
private static DateTimeOffset ReadDateTimeOffset(Document doc, string key)
|
||||
{
|
||||
var value = doc.GetString(key);
|
||||
|
|
|
|||
30
tests/playwright/date-locale.spec.ts
Normal file
30
tests/playwright/date-locale.spec.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.use({
|
||||
locale: 'en-US'
|
||||
});
|
||||
|
||||
async function waitForBlazorConnection(page: Parameters<typeof test>[0]['page']) {
|
||||
await page.waitForTimeout(750);
|
||||
}
|
||||
|
||||
test('date picker popup is monday-first and uses european formatting', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
await page.goto('/calendar-event', { waitUntil: 'networkidle' });
|
||||
await waitForBlazorConnection(page);
|
||||
|
||||
const startDateInput = page.getByTestId('calendar-event-start-date-input');
|
||||
await expect(startDateInput).toBeVisible();
|
||||
|
||||
await startDateInput.click();
|
||||
|
||||
const popup = page.getByTestId('calendar-event-start-date-popover');
|
||||
await expect(popup).toBeVisible();
|
||||
|
||||
const weekdayHeaders = popup.getByTestId('date-picker-weekday');
|
||||
await expect(weekdayHeaders).toHaveCount(7);
|
||||
await expect(weekdayHeaders.first()).toHaveText(/^(Mon|Lun)$/);
|
||||
|
||||
await page.getByTestId('calendar-event-start-date-day-2026-04-22').click();
|
||||
await expect(startDateInput).toHaveValue(/^22\/\d{2}\/\d{4}$/);
|
||||
});
|
||||
177
wwwroot/app.css
177
wwwroot/app.css
|
|
@ -484,6 +484,10 @@ h1:focus {
|
|||
box-shadow: inset 0 0 0 0.15rem var(--wt-calendar-active);
|
||||
}
|
||||
|
||||
.calendar-cell-outside-month {
|
||||
background-color: color-mix(in srgb, var(--bs-tertiary-bg) 72%, transparent);
|
||||
}
|
||||
|
||||
.calendar-cell-today::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
|
|
@ -505,6 +509,18 @@ h1:focus {
|
|||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.calendar-day-number-outside {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.calendar-day-month-label {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
.calendar-cell-today .calendar-day-number {
|
||||
background-color: var(--wt-calendar-today-badge-bg);
|
||||
color: var(--wt-calendar-today-badge-text);
|
||||
|
|
@ -622,6 +638,139 @@ h1:focus {
|
|||
text-align: left;
|
||||
}
|
||||
|
||||
.calendar-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: rgba(15, 23, 42, 0.38);
|
||||
}
|
||||
|
||||
.calendar-modal-shell {
|
||||
width: min(58rem, 100%);
|
||||
max-height: calc(100vh - 2rem);
|
||||
}
|
||||
|
||||
.calendar-modal-shell-compact {
|
||||
width: min(44rem, 100%);
|
||||
}
|
||||
|
||||
.calendar-modal-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 2rem);
|
||||
background: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 1.25rem 3rem rgba(0, 0, 0, 0.22);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calendar-modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.calendar-modal-body {
|
||||
padding: 1rem 1.25rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.calendar-modal-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid var(--bs-border-color);
|
||||
background: color-mix(in srgb, var(--bs-body-bg) 92%, var(--bs-tertiary-bg));
|
||||
}
|
||||
|
||||
.localized-date-input {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.localized-date-input-toggle {
|
||||
min-width: 3.25rem;
|
||||
}
|
||||
|
||||
.localized-date-input-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.35rem);
|
||||
left: 0;
|
||||
z-index: 1250;
|
||||
width: min(18rem, 100vw - 2rem);
|
||||
padding: 0.75rem;
|
||||
background: var(--bs-body-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.85rem;
|
||||
box-shadow: 0 1rem 2rem rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
.localized-date-input-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.localized-date-input-month {
|
||||
font-weight: 700;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.localized-date-input-weekdays,
|
||||
.localized-date-input-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.localized-date-input-weekdays {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.localized-date-input-weekday {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
.localized-date-input-day {
|
||||
border: 0;
|
||||
border-radius: 0.55rem;
|
||||
background: transparent;
|
||||
padding: 0.45rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.localized-date-input-day:hover {
|
||||
background: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
.localized-date-input-day-outside {
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
.localized-date-input-day-selected {
|
||||
background: var(--bs-primary);
|
||||
color: var(--bs-primary-bg-subtle, #fff);
|
||||
}
|
||||
|
||||
.localized-date-input-actions {
|
||||
display: flex;
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
||||
.calendar-legend-work {
|
||||
background-color: #cfe2ff;
|
||||
color: #14213d;
|
||||
|
|
@ -706,6 +855,34 @@ h1:focus {
|
|||
left: 0;
|
||||
width: calc(100vw - 2rem);
|
||||
}
|
||||
|
||||
.calendar-modal-backdrop {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.calendar-modal-shell,
|
||||
.calendar-modal-shell-compact {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.calendar-modal-dialog {
|
||||
max-height: calc(100vh - 1rem);
|
||||
}
|
||||
|
||||
.calendar-modal-header,
|
||||
.calendar-modal-body,
|
||||
.calendar-modal-actions {
|
||||
padding-left: 0.9rem;
|
||||
padding-right: 0.9rem;
|
||||
}
|
||||
|
||||
.calendar-modal-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.localized-date-input-popover {
|
||||
width: calc(100vw - 2rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* Monthly timesheet summary */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue