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,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");
}
}

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);
}

View 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");
}
}