feat: add yearly summary page with navigation and formatting improvements
All checks were successful
Publish Container / publish (push) Successful in 3m17s

This commit is contained in:
MaddoScientisto 2026-04-20 23:56:23 +02:00
commit 0d003903cf
12 changed files with 406 additions and 70 deletions

View file

@ -462,10 +462,7 @@ else
private static string FormatHours(decimal value)
{
var totalMinutes = (int)Math.Round(value * 60m, MidpointRounding.AwayFromZero);
var hours = totalMinutes / 60;
var minutes = totalMinutes % 60;
return $"{hours:00}:{minutes:00}";
return DurationFormatter.FormatHours(value);
}
private sealed class CalendarCell

View file

@ -234,10 +234,7 @@ else
private static string FormatHours(decimal value)
{
var totalMinutes = (int)Math.Round(value * 60m, MidpointRounding.AwayFromZero);
var hours = totalMinutes / 60;
var minutes = totalMinutes % 60;
return $"{hours:00}:{minutes:00}";
return DurationFormatter.FormatHours(value);
}
private sealed class CalendarDayRow

View file

@ -41,6 +41,15 @@
</div>
</div>
</div>
<div class="col-12 col-md-6 col-xl-4">
<div class="card h-100">
<div class="card-body">
<h2 class="h5 card-title">Yearly Summary</h2>
<p class="mb-2">Month-by-month totals in a single table for the selected year.</p>
<a href="yearly-summary" class="btn btn-outline-primary">Open Yearly Summary</a>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-xl-4">
<div class="card h-100">
<div class="card-body">

View file

@ -6,6 +6,7 @@
@using System.Globalization
@inject global::WorkTracker.Services.WorkDays.IWorkDayService WorkDayService
@inject IJSRuntime JS
@inject NavigationManager Navigation
<PageTitle>Monthly Summary</PageTitle>
@ -15,6 +16,7 @@
<button class="btn btn-outline-secondary btn-sm" @onclick="PreviousMonth">&laquo; Prev</button>
<h2 class="h5 mb-0">@currentMonth.ToString("MMMM yyyy")</h2>
<button class="btn btn-outline-secondary btn-sm" @onclick="NextMonth">Next &raquo;</button>
<a class="btn btn-outline-primary btn-sm ms-auto" href="yearly-summary/@currentMonth.Year">Yearly Summary</a>
</div>
<div class="form-check mb-3">
@ -219,17 +221,9 @@ else if (viewMode == SummaryViewMode.Timesheet && timesheet is not null)
private global::WorkTracker.Domain.MonthlyTimesheetModel? timesheet;
private SummaryViewMode viewMode = SummaryViewMode.Timesheet;
protected override async Task OnInitializedAsync()
protected override async Task OnParametersSetAsync()
{
if (!string.IsNullOrEmpty(YearMonth) && DateTime.TryParseExact(YearMonth, "yyyy-MM", null, System.Globalization.DateTimeStyles.None, out var parsed))
{
currentMonth = new DateOnly(parsed.Year, parsed.Month, 1);
}
else
{
currentMonth = new DateOnly(DateTime.Today.Year, DateTime.Today.Month, 1);
}
currentMonth = ParseCurrentMonth();
await LoadSummary();
}
@ -264,16 +258,18 @@ else if (viewMode == SummaryViewMode.Timesheet && timesheet is not null)
loading = false;
}
private async Task PreviousMonth()
private Task PreviousMonth()
{
currentMonth = currentMonth.AddMonths(-1);
await LoadSummary();
Navigation.NavigateTo($"/summary/{currentMonth:yyyy-MM}");
return Task.CompletedTask;
}
private async Task NextMonth()
private Task NextMonth()
{
currentMonth = currentMonth.AddMonths(1);
await LoadSummary();
Navigation.NavigateTo($"/summary/{currentMonth:yyyy-MM}");
return Task.CompletedTask;
}
private void SetViewMode(SummaryViewMode mode)
@ -286,6 +282,16 @@ else if (viewMode == SummaryViewMode.Timesheet && timesheet is not null)
return ItalianCulture.TextInfo.ToTitleCase(date.ToString("ddd", ItalianCulture));
}
private DateOnly ParseCurrentMonth()
{
if (!string.IsNullOrEmpty(YearMonth) && DateTime.TryParseExact(YearMonth, "yyyy-MM", null, DateTimeStyles.None, out var parsed))
{
return new DateOnly(parsed.Year, parsed.Month, 1);
}
return new DateOnly(DateTime.Today.Year, DateTime.Today.Month, 1);
}
private static string GetDayColumnClass(global::WorkTracker.Domain.MonthlyTimesheetDayModel day)
{
if (day.IsWeekend || day.IsHoliday)
@ -320,15 +326,12 @@ else if (viewMode == SummaryViewMode.Timesheet && timesheet is not null)
private static string FormatDecimalHours(decimal value)
{
return value.ToString("0.##", ItalianCulture);
return DurationFormatter.FormatHours(value, blankWhenZero: true);
}
private static string FormatHours(decimal value)
{
var totalMinutes = (int)Math.Round(value * 60m, MidpointRounding.AwayFromZero);
var hours = totalMinutes / 60;
var minutes = totalMinutes % 60;
return $"{hours:00}:{minutes:00}";
return DurationFormatter.FormatHours(value);
}
private enum SummaryViewMode

View file

@ -383,23 +383,13 @@ else
return false;
}
private static string FormatHours(decimal? value) => value.HasValue ? FormatDurationHours(value.Value) : "—";
private static string FormatHours(decimal? value) => value.HasValue ? DurationFormatter.FormatHours(value.Value) : "—";
private static string FormatSignedHours(decimal value) => value switch
{
> 0 => $"+{FormatDurationHours(value)}",
< 0 => $"-{FormatDurationHours(Math.Abs(value))}",
_ => "00:00"
};
private static string FormatSignedHours(decimal value) => DurationFormatter.FormatSignedHours(value);
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}";
return DurationFormatter.FormatHours(value);
}
protected override void OnParametersSet()

View file

@ -0,0 +1,205 @@
@page "/yearly-summary"
@page "/yearly-summary/{Year:int}"
@attribute [Authorize]
@rendermode InteractiveServer
@using System.Globalization
@inject global::WorkTracker.Services.WorkDays.IWorkDayService WorkDayService
@inject IJSRuntime JS
@inject NavigationManager Navigation
<PageTitle>Yearly Summary</PageTitle>
<h1>Yearly Summary</h1>
<div class="d-flex align-items-center gap-2 mb-3">
<button class="btn btn-outline-secondary btn-sm" @onclick="PreviousYear">&laquo; Prev</button>
<h2 class="h5 mb-0">@currentYear</h2>
<button class="btn btn-outline-secondary btn-sm" @onclick="NextYear">Next &raquo;</button>
<a class="btn btn-outline-primary btn-sm ms-auto" href="summary">Monthly Summary</a>
</div>
<div class="form-check mb-3">
<input id="include-preview-yearly" type="checkbox" class="form-check-input" checked="@includePreview" @onchange="OnIncludePreviewChanged" />
<label class="form-check-label" for="include-preview-yearly">Include preview work units in totals</label>
</div>
@if (loading)
{
<p><em>Loading...</em></p>
}
else
{
<div class="yearly-summary-card card border-0 shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-bordered table-sm align-middle mb-0 yearly-summary-table">
<thead>
<tr>
<th class="yearly-summary-sticky-column">Month</th>
<th class="text-end yearly-summary-header-cell">Working Days</th>
<th class="text-end yearly-summary-header-cell">Worked Hours</th>
<th class="text-end yearly-summary-header-cell">Hours Off</th>
<th class="text-end yearly-summary-header-cell text-success">Gross Income</th>
<th class="text-end yearly-summary-header-cell text-primary">Net Income</th>
<th class="text-end yearly-summary-header-cell">Office Days</th>
<th class="text-end yearly-summary-header-cell">Home Days</th>
<th class="text-end yearly-summary-header-cell">Holidays</th>
<th class="text-end yearly-summary-header-cell">Closure Days</th>
<th class="text-end yearly-summary-header-cell">Days Off</th>
<th class="text-end yearly-summary-header-cell">Sick Days</th>
</tr>
</thead>
<tbody>
@foreach (var month in summaryByMonth)
{
<tr>
<th scope="row" class="yearly-summary-sticky-column">
<a href="summary/@GetYearMonth(month)">@GetMonthLabel(month)</a>
</th>
<td class="text-end">@FormatCount(month.TotalWorkingDays)</td>
<td class="text-end">@FormatHoursCell(month.TotalWorkedHours)</td>
<td class="text-end">@FormatHoursCell(month.TotalHoursOff)</td>
<td class="text-end text-success">@FormatCurrency(month.TotalGrossIncome)</td>
<td class="text-end text-primary">@FormatCurrency(month.TotalNetIncome)</td>
<td class="text-end">@FormatCount(month.OfficeDays)</td>
<td class="text-end">@FormatCount(month.HomeDays)</td>
<td class="text-end">@FormatCount(month.HolidayDays)</td>
<td class="text-end">@FormatCount(month.ClosureDays)</td>
<td class="text-end">@FormatCount(month.DaysOff)</td>
<td class="text-end">@FormatCount(month.SickDays)</td>
</tr>
}
</tbody>
<tfoot>
<tr class="yearly-summary-total-row">
<th class="yearly-summary-sticky-column">Total</th>
<td class="text-end">@FormatCount(Totals.TotalWorkingDays)</td>
<td class="text-end">@FormatHoursCell(Totals.TotalWorkedHours)</td>
<td class="text-end">@FormatHoursCell(Totals.TotalHoursOff)</td>
<td class="text-end text-success">@FormatCurrency(Totals.TotalGrossIncome)</td>
<td class="text-end text-primary">@FormatCurrency(Totals.TotalNetIncome)</td>
<td class="text-end">@FormatCount(Totals.OfficeDays)</td>
<td class="text-end">@FormatCount(Totals.HomeDays)</td>
<td class="text-end">@FormatCount(Totals.HolidayDays)</td>
<td class="text-end">@FormatCount(Totals.ClosureDays)</td>
<td class="text-end">@FormatCount(Totals.DaysOff)</td>
<td class="text-end">@FormatCount(Totals.SickDays)</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
}
@code {
[Parameter] public int? Year { get; set; }
private static readonly CultureInfo ItalianCulture = CultureInfo.GetCultureInfo("it-IT");
private const string IncludePreviewPreferenceKey = "worktracker.includePreviewWorkUnits";
private int currentYear;
private bool loading = true;
private bool includePreview;
private IReadOnlyList<global::WorkTracker.Domain.MonthlySummaryModel> summaryByMonth = [];
private global::WorkTracker.Domain.MonthlySummaryModel Totals => new()
{
Year = currentYear,
TotalWorkingDays = summaryByMonth.Sum(item => item.TotalWorkingDays),
CountedWorkUnits = summaryByMonth.Sum(item => item.CountedWorkUnits),
TotalWorkedHours = summaryByMonth.Sum(item => item.TotalWorkedHours),
TotalPreviewWorkedHours = summaryByMonth.Sum(item => item.TotalPreviewWorkedHours),
PreviewWorkUnits = summaryByMonth.Sum(item => item.PreviewWorkUnits),
TotalHoursOff = summaryByMonth.Sum(item => item.TotalHoursOff),
TotalGrossIncome = summaryByMonth.Sum(item => item.TotalGrossIncome),
TotalNetIncome = summaryByMonth.Sum(item => item.TotalNetIncome),
OfficeDays = summaryByMonth.Sum(item => item.OfficeDays),
HomeDays = summaryByMonth.Sum(item => item.HomeDays),
HolidayDays = summaryByMonth.Sum(item => item.HolidayDays),
ClosureDays = summaryByMonth.Sum(item => item.ClosureDays),
DaysOff = summaryByMonth.Sum(item => item.DaysOff),
SickDays = summaryByMonth.Sum(item => item.SickDays)
};
protected override async Task OnParametersSetAsync()
{
currentYear = Year ?? DateTime.Today.Year;
await LoadSummary();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
var savedIncludePreview = await JS.InvokeAsync<bool?>("workTrackerPreferences.getBool", IncludePreviewPreferenceKey);
if (savedIncludePreview.HasValue && savedIncludePreview.Value != includePreview)
{
includePreview = savedIncludePreview.Value;
await LoadSummary();
await InvokeAsync(StateHasChanged);
}
}
private async Task LoadSummary()
{
loading = true;
summaryByMonth = await WorkDayService.GetYearlySummaryAsync(currentYear, includePreview);
loading = false;
}
private async Task OnIncludePreviewChanged(ChangeEventArgs e)
{
includePreview = e.Value is bool value && value;
await JS.InvokeVoidAsync("workTrackerPreferences.setBool", IncludePreviewPreferenceKey, includePreview);
await LoadSummary();
}
private Task PreviousYear()
{
currentYear--;
Navigation.NavigateTo($"/yearly-summary/{currentYear}");
return Task.CompletedTask;
}
private Task NextYear()
{
currentYear++;
Navigation.NavigateTo($"/yearly-summary/{currentYear}");
return Task.CompletedTask;
}
private static string GetMonthLabel(global::WorkTracker.Domain.MonthlySummaryModel month)
{
return new DateOnly(month.Year, month.Month, 1).ToString("MMMM", ItalianCulture);
}
private static string GetYearMonth(global::WorkTracker.Domain.MonthlySummaryModel month)
{
return $"{month.Year:D4}-{month.Month:D2}";
}
private static string FormatHours(decimal value)
{
return DurationFormatter.FormatHours(value);
}
private static string FormatHoursCell(decimal value)
{
return DurationFormatter.FormatHours(value, blankWhenZero: true);
}
private static string FormatCount(int value)
{
return value == 0 ? string.Empty : value.ToString(CultureInfo.InvariantCulture);
}
private static string FormatCurrency(decimal value)
{
return value == 0m ? string.Empty : $"€{value.ToString("N2", ItalianCulture)}";
}
}