Refactor AppSettingsDocument and CoeffSnapshotDocument: Remove LunchBreakHours property

Add CalendarEventDocument and CalendarEventType enum for event management

Update WorkDayDocument to include WorkUnitDocument and CalendarEventDocument lists

Enhance CouchbaseLiteWorkDayService with methods for managing WorkUnit and CalendarEvent

Revise MonthlySummaryModel to track preview worked hours and counted work units

Improve CSS for calendar view, including responsive design and new item styles
This commit is contained in:
Marco 2026-04-20 16:11:27 +02:00
commit cab549ab3a
22 changed files with 1725 additions and 356 deletions

View file

@ -1,16 +1,17 @@
@page "/workday"
@page "/workday/{DateStr}"
@page "/work-unit"
@page "/work-unit/{DateStr}"
@page "/work-unit/{DateStr}/{UnitId}"
@attribute [Authorize]
@rendermode InteractiveServer
@inject IWorkDayService WorkDayService
@inject IAppSettingsService AppSettingsService
@inject IItalianFestivitySource FestivitySource
@inject NavigationManager Navigation
@inject IJSRuntime JS
<PageTitle>Work Day</PageTitle>
<PageTitle>Work Unit</PageTitle>
<h1>Work Day Entry</h1>
<h1>Work Unit</h1>
@if (!loaded)
{
@ -21,22 +22,20 @@ 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" />
@if (isWeekend || isFestivity)
{
<div class="mt-1">
@if (isWeekend) { <span class="badge bg-danger me-1">Weekend</span> }
@if (isFestivity) { <span class="badge bg-warning text-dark">Festivity</span> }
</div>
}
<input type="date" class="form-control" value="@selectedDate.ToString("yyyy-MM-dd")" @onchange="OnDateChanged" disabled="@isExistingUnit" />
</div>
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">Day Type</label>
<select class="form-select" value="@selectedDayType" @onchange="OnDayTypeChanged">
@foreach (var dt in Enum.GetValues<DayType>())
<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="@dt">@dt</option>
<option value="@item">@item</option>
}
</select>
</div>
@ -47,14 +46,20 @@ else
</div>
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">Actual Exit Time</label>
<input type="time" class="form-control" value="@actualExitTimeStr" @onchange="OnActualExitChanged" />
<div class="form-text">Informational only, not used in calculations.</div>
<label class="form-label">End Time</label>
<input type="time" class="form-control" value="@endTimeStr" @onchange="OnEndTimeChanged" />
</div>
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">Extra hours delta</label>
<input type="number" class="form-control" step="0.25" value="@extraHoursDelta" @onchange="OnExtraDeltaChanged" />
<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" type="checkbox" class="form-check-input" @bind="isPreview" />
<label class="form-check-label" for="preview-checkbox">Preview work unit</label>
</div>
</div>
<div class="col-12">
@ -68,20 +73,12 @@ else
<h2 class="h5">Computed values</h2>
<div class="row g-3">
<div class="col-6 col-md-4 col-lg-3">
<label class="form-label text-muted">Projected Exit</label>
<div class="form-control-plaintext fw-bold">@(projectedExitTime?.ToString("HH:mm") ?? "—")</div>
<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">Worked (base)</label>
<div class="form-control-plaintext fw-bold">@workedHoursBase.ToString("N2")h</div>
</div>
<div class="col-6 col-md-4 col-lg-3">
<label class="form-label text-muted">Worked (final)</label>
<div class="form-control-plaintext fw-bold">@workedHoursFinal.ToString("N2")h</div>
</div>
<div class="col-6 col-md-4 col-lg-3">
<label class="form-label text-muted">Hours Off</label>
<div class="form-control-plaintext fw-bold">@hoursOff.ToString("N2")h</div>
<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>
@ -93,8 +90,27 @@ else
</div>
</div>
<div class="mt-4">
<h2 class="h5">Day Total</h2>
<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>
<div class="d-flex align-items-center gap-2 mt-4">
<button class="btn btn-primary" @onclick="SaveAsync">Save</button>
@if (isExistingUnit)
{
<button class="btn btn-outline-danger" @onclick="DeleteAsync">Delete</button>
}
<button class="btn btn-outline-secondary" @onclick="BackToCalendar">Back to Calendar</button>
@if (!string.IsNullOrWhiteSpace(statusMessage))
{
<span class="text-success">@statusMessage</span>
@ -104,29 +120,31 @@ else
@code {
[Parameter] public string? DateStr { get; set; }
[Parameter] public string? UnitId { get; set; }
private bool loaded;
private DateOnly selectedDate = DateOnly.FromDateTime(DateTime.Today);
private DayType selectedDayType = DayType.None;
private string unitId = string.Empty;
private string label = "Work unit";
private WorkUnitLocation location = WorkUnitLocation.Office;
private string? startTimeStr;
private string? actualExitTimeStr;
private decimal extraHoursDelta;
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;
// Computed preview
private TimeOnly? projectedExitTime;
private decimal workedHoursBase;
private decimal workedHoursFinal;
private decimal hoursOff;
private decimal grossIncome;
private decimal netIncome;
private bool isWeekend;
private bool isFestivity;
private decimal? calculatedWorkedHours;
private decimal workedHoursDelta;
private decimal dayTotalHours;
private int dayWorkUnitCount;
// Loaded from settings
private AppSettingsDocument settings = new();
private IReadOnlyCollection<DateOnly> festivities = [];
protected override async Task OnInitializedAsync()
{
@ -136,33 +154,41 @@ else
}
settings = await AppSettingsService.GetAsync();
festivities = FestivitySource.GetFestivities(selectedDate.Year);
await LoadExistingEntry();
RecomputeFlags();
RecomputePreview();
await LoadUnitAsync();
loaded = true;
}
private async Task LoadExistingEntry()
private async Task LoadUnitAsync()
{
var existing = await WorkDayService.GetAsync(selectedDate);
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)
{
selectedDayType = existing.DayType;
unitId = existing.Id;
label = existing.Label;
location = existing.Location;
startTimeStr = existing.StartTime?.ToString("HH:mm");
actualExitTimeStr = existing.ActualExitTime?.ToString("HH:mm");
extraHoursDelta = existing.ExtraHoursDelta;
endTimeStr = existing.EndTime?.ToString("HH:mm");
manualWorkedHours = existing.ManualWorkedHours;
manualWorkedHoursStr = FormatDurationHours(existing.ManualWorkedHours);
isPreview = existing.IsPreview;
notes = existing.Notes;
isExistingUnit = true;
}
else
{
selectedDayType = DayType.None;
startTimeStr = null;
actualExitTimeStr = null;
extraHoursDelta = 0;
notes = null;
SetDefaults();
statusMessage = "The selected work unit was not found. A new unit will be created for this day.";
}
RecomputePreview();
}
private async Task OnDateChanged(ChangeEventArgs e)
@ -170,127 +196,214 @@ else
if (DateOnly.TryParse(e.Value?.ToString(), out var d))
{
selectedDate = d;
festivities = FestivitySource.GetFestivities(selectedDate.Year);
await LoadExistingEntry();
RecomputeFlags();
RecomputePreview();
statusMessage = null;
}
}
private void OnDayTypeChanged(ChangeEventArgs e)
{
if (Enum.TryParse<DayType>(e.Value?.ToString(), out var dt))
{
selectedDayType = dt;
RecomputePreview();
statusMessage = null;
}
}
private void OnStartTimeChanged(ChangeEventArgs e)
private Task OnStartTimeChanged(ChangeEventArgs e)
{
startTimeStr = e.Value?.ToString();
RecomputePreview();
SyncManualHoursToCalculated();
statusMessage = null;
return Task.CompletedTask;
}
private void OnActualExitChanged(ChangeEventArgs e)
private Task OnEndTimeChanged(ChangeEventArgs e)
{
actualExitTimeStr = e.Value?.ToString();
endTimeStr = e.Value?.ToString();
SyncManualHoursToCalculated();
statusMessage = null;
return Task.CompletedTask;
}
private void OnExtraDeltaChanged(ChangeEventArgs e)
private Task OnManualWorkedHoursChanged(ChangeEventArgs e)
{
if (decimal.TryParse(e.Value?.ToString(), out var val))
var rawValue = e.Value?.ToString();
if (TryParseDurationHours(rawValue, out var parsedHours))
{
extraHoursDelta = val;
manualWorkedHours = parsedHours;
manualWorkedHoursStr = FormatDurationHours(parsedHours);
}
else
{
manualWorkedHoursStr = FormatDurationHours(manualWorkedHours);
}
RecomputePreview();
statusMessage = null;
return Task.CompletedTask;
}
private void RecomputeFlags()
private void SetDefaults()
{
isWeekend = selectedDate.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
isFestivity = festivities.Contains(selectedDate);
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()
{
TimeOnly? start = null;
if (!string.IsNullOrEmpty(startTimeStr) && TimeOnly.TryParse(startTimeStr, out var s))
{
start = s;
}
if (selectedDayType is DayType.Work or DayType.Home)
{
workedHoursBase = settings.StandardWorkHoursPerDay;
if (start.HasValue)
{
var totalHours = settings.StandardWorkHoursPerDay + settings.LunchBreakHours;
projectedExitTime = start.Value.Add(TimeSpan.FromHours((double)totalHours));
}
else
{
projectedExitTime = null;
}
}
else
{
workedHoursBase = 0;
projectedExitTime = null;
}
workedHoursFinal = workedHoursBase + extraHoursDelta;
hoursOff = selectedDayType is DayType.Work or DayType.Home
? Math.Max(0, settings.StandardWorkHoursPerDay - workedHoursFinal)
: 0;
grossIncome = workedHoursFinal * settings.HourlyGrossRate;
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()
{
TimeOnly? start = null;
TimeOnly? exit = null;
RecomputePreview();
if (!string.IsNullOrEmpty(startTimeStr) && TimeOnly.TryParse(startTimeStr, out var s))
var workUnit = new WorkUnitDocument
{
start = s;
}
if (!string.IsNullOrEmpty(actualExitTimeStr) && TimeOnly.TryParse(actualExitTimeStr, out var e2))
{
exit = e2;
}
var workDay = new WorkDayDocument
{
Date = selectedDate,
DayType = selectedDayType,
StartTime = start,
ActualExitTime = exit,
ExtraHoursDelta = extraHoursDelta,
Id = unitId,
Label = label,
Location = location,
StartTime = ParseTime(startTimeStr),
EndTime = ParseTime(endTimeStr),
ManualWorkedHours = Math.Max(0m, manualWorkedHours),
IsPreview = isPreview,
Notes = notes
};
var saved = await WorkDayService.SaveAsync(workDay);
var saved = await WorkDayService.SaveWorkUnitAsync(selectedDate, workUnit);
// Update preview with saved computed values
projectedExitTime = saved.ProjectedExitTime;
workedHoursBase = saved.WorkedHoursBase;
workedHoursFinal = saved.WorkedHoursFinal;
hoursOff = saved.HoursOff;
unitId = saved.Id;
isExistingUnit = true;
label = saved.Label;
location = saved.Location;
startTimeStr = saved.StartTime?.ToString("HH:mm");
endTimeStr = saved.EndTime?.ToString("HH:mm");
manualWorkedHours = saved.ManualWorkedHours;
manualWorkedHoursStr = FormatDurationHours(saved.ManualWorkedHours);
isPreview = saved.IsPreview;
notes = saved.Notes;
calculatedWorkedHours = saved.CalculatedWorkedHours;
workedHoursDelta = saved.WorkedHoursDelta;
grossIncome = saved.GrossIncome;
netIncome = saved.NetIncome;
isWeekend = saved.IsWeekend;
isFestivity = saved.IsItalianFestivity;
statusMessage = $"Saved at {DateTime.Now:t}";
Navigation.NavigateTo($"/calendar/{selectedDate:yyyy-MM}");
}
private async Task DeleteAsync()
{
if (!isExistingUnit || string.IsNullOrWhiteSpace(unitId))
{
return;
}
var confirmed = await JS.InvokeAsync<bool>("confirm", $"Delete work unit '{label}' on {selectedDate:dddd d MMMM}?\nThis cannot be undone.");
if (!confirmed)
{
return;
}
var deleted = await WorkDayService.DeleteWorkUnitAsync(selectedDate, unitId);
if (deleted)
{
Navigation.NavigateTo($"/calendar/{selectedDate:yyyy-MM}");
return;
}
statusMessage = "Unable to delete the work unit.";
}
private void BackToCalendar()
{
Navigation.NavigateTo($"/calendar/{selectedDate:yyyy-MM}");
}
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 bool TryParseDurationHours(string? value, out decimal hours)
{
hours = 0m;
if (string.IsNullOrWhiteSpace(value))
{
return true;
}
if (TimeSpan.TryParseExact(value, [@"h\:mm", @"hh\:mm"], null, out var timeSpan))
{
hours = Math.Round((decimal)timeSpan.TotalMinutes / 60m, 2, MidpointRounding.AwayFromZero);
return true;
}
if (decimal.TryParse(value, out var decimalHours))
{
hours = Math.Max(0m, decimalHours);
return true;
}
return false;
}
private static string FormatHours(decimal? value) => value.HasValue ? FormatDurationHours(value.Value) : "—";
private static string FormatSignedHours(decimal value) => value switch
{
> 0 => $"+{FormatDurationHours(value)}",
< 0 => $"-{FormatDurationHours(Math.Abs(value))}",
_ => "00:00"
};
private static string FormatDurationHours(decimal value)
{
var totalMinutes = (int)Math.Round(value * 60m, MidpointRounding.AwayFromZero);
var sign = totalMinutes < 0 ? "-" : string.Empty;
totalMinutes = Math.Abs(totalMinutes);
var hours = totalMinutes / 60;
var minutes = totalMinutes % 60;
return $"{sign}{hours:00}:{minutes:00}";
}
protected override void OnParametersSet()
{
RecomputePreview();
}
}