2026-03-17 22:10:19 +01:00
|
|
|
@page "/summary"
|
|
|
|
|
@page "/summary/{YearMonth}"
|
|
|
|
|
@attribute [Authorize]
|
|
|
|
|
@rendermode InteractiveServer
|
|
|
|
|
|
2026-04-20 17:23:54 +02:00
|
|
|
@using System.Globalization
|
|
|
|
|
@inject global::WorkTracker.Services.WorkDays.IWorkDayService WorkDayService
|
2026-04-20 23:17:35 +02:00
|
|
|
@inject IJSRuntime JS
|
2026-04-20 23:56:23 +02:00
|
|
|
@inject NavigationManager Navigation
|
2026-03-17 22:10:19 +01:00
|
|
|
|
|
|
|
|
<PageTitle>Monthly Summary</PageTitle>
|
|
|
|
|
|
|
|
|
|
<h1>Monthly Summary</h1>
|
|
|
|
|
|
|
|
|
|
<div class="d-flex align-items-center gap-2 mb-3">
|
|
|
|
|
<button class="btn btn-outline-secondary btn-sm" @onclick="PreviousMonth">« Prev</button>
|
|
|
|
|
<h2 class="h5 mb-0">@currentMonth.ToString("MMMM yyyy")</h2>
|
|
|
|
|
<button class="btn btn-outline-secondary btn-sm" @onclick="NextMonth">Next »</button>
|
2026-04-24 10:45:44 +02:00
|
|
|
<a class="btn btn-success btn-sm ms-auto" href="@GetExcelDownloadUrl()">Download Excel</a>
|
2026-04-20 23:56:23 +02:00
|
|
|
<a class="btn btn-outline-primary btn-sm ms-auto" href="yearly-summary/@currentMonth.Year">Yearly Summary</a>
|
2026-03-17 22:10:19 +01:00
|
|
|
</div>
|
|
|
|
|
|
2026-04-20 16:11:27 +02:00
|
|
|
<div class="form-check mb-3">
|
|
|
|
|
<input id="include-preview" type="checkbox" class="form-check-input" checked="@includePreview" @onchange="OnIncludePreviewChanged" />
|
|
|
|
|
<label class="form-check-label" for="include-preview">Include preview work units in totals</label>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-04-20 17:23:54 +02:00
|
|
|
<div class="btn-group mb-3" role="group" aria-label="Summary view selector">
|
|
|
|
|
<button type="button" class="btn @(viewMode == SummaryViewMode.Cards ? "btn-primary" : "btn-outline-primary")" @onclick="() => SetViewMode(SummaryViewMode.Cards)">Cards</button>
|
|
|
|
|
<button type="button" class="btn @(viewMode == SummaryViewMode.Timesheet ? "btn-primary" : "btn-outline-primary")" @onclick="() => SetViewMode(SummaryViewMode.Timesheet)">Timesheet</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-17 22:10:19 +01:00
|
|
|
@if (loading)
|
|
|
|
|
{
|
|
|
|
|
<p><em>Loading...</em></p>
|
|
|
|
|
}
|
2026-04-20 17:23:54 +02:00
|
|
|
else if (viewMode == SummaryViewMode.Cards && summary is not null)
|
2026-03-17 22:10:19 +01:00
|
|
|
{
|
|
|
|
|
<div class="row g-3">
|
|
|
|
|
<div class="col-6 col-md-4 col-xl-3">
|
|
|
|
|
<div class="card text-center h-100">
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
<div class="text-muted small">Working Days</div>
|
|
|
|
|
<div class="fs-3 fw-bold">@summary.TotalWorkingDays</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-04-20 16:11:27 +02:00
|
|
|
<div class="col-6 col-md-4 col-xl-3">
|
|
|
|
|
<div class="card text-center h-100">
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
<div class="text-muted small">Counted Work Units</div>
|
|
|
|
|
<div class="fs-3 fw-bold">@summary.CountedWorkUnits</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-03-17 22:10:19 +01:00
|
|
|
<div class="col-6 col-md-4 col-xl-3">
|
|
|
|
|
<div class="card text-center h-100">
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
<div class="text-muted small">Total Worked Hours</div>
|
2026-04-20 16:11:27 +02:00
|
|
|
<div class="fs-3 fw-bold">@FormatHours(summary.TotalWorkedHours)</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-6 col-md-4 col-xl-3">
|
|
|
|
|
<div class="card text-center h-100">
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
<div class="text-muted small">Preview Hours</div>
|
|
|
|
|
<div class="fs-3 fw-bold">@FormatHours(summary.TotalPreviewWorkedHours)</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-6 col-md-4 col-xl-3">
|
|
|
|
|
<div class="card text-center h-100">
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
<div class="text-muted small">Preview Units</div>
|
|
|
|
|
<div class="fs-3 fw-bold">@summary.PreviewWorkUnits</div>
|
2026-03-17 22:10:19 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-6 col-md-4 col-xl-3">
|
|
|
|
|
<div class="card text-center h-100">
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
<div class="text-muted small">Hours Off</div>
|
2026-04-20 16:11:27 +02:00
|
|
|
<div class="fs-3 fw-bold">@FormatHours(summary.TotalHoursOff)</div>
|
2026-03-17 22:10:19 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-6 col-md-4 col-xl-3">
|
|
|
|
|
<div class="card text-center h-100 border-success">
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
<div class="text-muted small">Gross Income</div>
|
|
|
|
|
<div class="fs-3 fw-bold text-success">€@summary.TotalGrossIncome.ToString("N2")</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-6 col-md-4 col-xl-3">
|
|
|
|
|
<div class="card text-center h-100 border-primary">
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
<div class="text-muted small">Net Income</div>
|
|
|
|
|
<div class="fs-3 fw-bold text-primary">€@summary.TotalNetIncome.ToString("N2")</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-6 col-md-4 col-xl-3">
|
|
|
|
|
<div class="card text-center h-100">
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
<div class="text-muted small">Office Days</div>
|
|
|
|
|
<div class="fs-3 fw-bold">@summary.OfficeDays</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-6 col-md-4 col-xl-3">
|
|
|
|
|
<div class="card text-center h-100">
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
<div class="text-muted small">Home Days</div>
|
|
|
|
|
<div class="fs-3 fw-bold">@summary.HomeDays</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-6 col-md-4 col-xl-3">
|
|
|
|
|
<div class="card text-center h-100">
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
<div class="text-muted small">Holidays</div>
|
|
|
|
|
<div class="fs-3 fw-bold">@summary.HolidayDays</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-6 col-md-4 col-xl-3">
|
|
|
|
|
<div class="card text-center h-100">
|
|
|
|
|
<div class="card-body">
|
2026-04-20 16:11:27 +02:00
|
|
|
<div class="text-muted small">Closure Days</div>
|
|
|
|
|
<div class="fs-3 fw-bold">@summary.ClosureDays</div>
|
2026-03-17 22:10:19 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-6 col-md-4 col-xl-3">
|
|
|
|
|
<div class="card text-center h-100">
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
<div class="text-muted small">Days Off</div>
|
|
|
|
|
<div class="fs-3 fw-bold">@summary.DaysOff</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-6 col-md-4 col-xl-3">
|
|
|
|
|
<div class="card text-center h-100">
|
|
|
|
|
<div class="card-body">
|
2026-04-20 16:11:27 +02:00
|
|
|
<div class="text-muted small">Sick Days</div>
|
|
|
|
|
<div class="fs-3 fw-bold">@summary.SickDays</div>
|
2026-03-17 22:10:19 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
2026-04-20 17:23:54 +02:00
|
|
|
else if (viewMode == SummaryViewMode.Timesheet && timesheet is not null)
|
|
|
|
|
{
|
|
|
|
|
<div class="timesheet-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 timesheet-summary-table">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th class="timesheet-summary-sticky-column">Categoria</th>
|
|
|
|
|
@for (var i = 0; i < timesheet.Days.Count; i++)
|
|
|
|
|
{
|
|
|
|
|
var day = timesheet.Days[i];
|
|
|
|
|
<th class="text-center timesheet-summary-day-header @GetDayColumnClass(day) @GetDayPopupClass(i, timesheet.Days.Count)">
|
|
|
|
|
<div>@day.Date.Day</div>
|
|
|
|
|
<div class="small text-muted">@GetDayHeader(day.Date)</div>
|
|
|
|
|
<div class="timesheet-summary-day-popup">
|
|
|
|
|
<div class="fw-semibold mb-2">@day.Date.ToString("dddd d MMMM", ItalianCulture)</div>
|
|
|
|
|
@if (day.WorkUnitSummaries.Count == 0 && day.EventSummaries.Count == 0)
|
|
|
|
|
{
|
|
|
|
|
<div class="small text-muted">Nessun elemento registrato.</div>
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
@foreach (var workUnit in day.WorkUnitSummaries)
|
|
|
|
|
{
|
|
|
|
|
<div class="timesheet-summary-day-popup-item">@workUnit</div>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@foreach (var calendarEvent in day.EventSummaries)
|
|
|
|
|
{
|
|
|
|
|
<div class="timesheet-summary-day-popup-item timesheet-summary-day-popup-item-event">@calendarEvent</div>
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</div>
|
|
|
|
|
</th>
|
|
|
|
|
}
|
|
|
|
|
<th class="text-center timesheet-summary-total-column">Totale</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
@foreach (var row in timesheet.Rows)
|
|
|
|
|
{
|
|
|
|
|
<tr>
|
|
|
|
|
<th scope="row" class="timesheet-summary-sticky-column">@row.Label</th>
|
|
|
|
|
@for (var i = 0; i < row.DailyValues.Count; i++)
|
|
|
|
|
{
|
|
|
|
|
<td class="text-center @GetDayColumnClass(timesheet.Days[i])">@FormatTimesheetValue(row.DailyValues[i], row.ValueFormat)</td>
|
|
|
|
|
}
|
|
|
|
|
<td class="text-center fw-semibold timesheet-summary-total-column">@FormatTimesheetValue(row.Total, row.ValueFormat)</td>
|
|
|
|
|
</tr>
|
|
|
|
|
}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
2026-03-17 22:10:19 +01:00
|
|
|
|
|
|
|
|
@code {
|
|
|
|
|
[Parameter] public string? YearMonth { get; set; }
|
|
|
|
|
|
2026-04-20 17:23:54 +02:00
|
|
|
private static readonly CultureInfo ItalianCulture = CultureInfo.GetCultureInfo("it-IT");
|
2026-04-20 23:17:35 +02:00
|
|
|
private const string IncludePreviewPreferenceKey = "worktracker.includePreviewWorkUnits";
|
2026-04-20 17:23:54 +02:00
|
|
|
|
2026-03-17 22:10:19 +01:00
|
|
|
private DateOnly currentMonth;
|
|
|
|
|
private bool loading = true;
|
2026-04-20 16:11:27 +02:00
|
|
|
private bool includePreview;
|
2026-04-20 17:23:54 +02:00
|
|
|
private global::WorkTracker.Domain.MonthlySummaryModel? summary;
|
|
|
|
|
private global::WorkTracker.Domain.MonthlyTimesheetModel? timesheet;
|
|
|
|
|
private SummaryViewMode viewMode = SummaryViewMode.Timesheet;
|
2026-03-17 22:10:19 +01:00
|
|
|
|
2026-04-20 23:56:23 +02:00
|
|
|
protected override async Task OnParametersSetAsync()
|
2026-03-17 22:10:19 +01:00
|
|
|
{
|
2026-04-20 23:56:23 +02:00
|
|
|
currentMonth = ParseCurrentMonth();
|
2026-03-17 22:10:19 +01:00
|
|
|
await LoadSummary();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 23:17:35 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 16:11:27 +02:00
|
|
|
private async Task OnIncludePreviewChanged(ChangeEventArgs e)
|
|
|
|
|
{
|
|
|
|
|
includePreview = e.Value is bool value && value;
|
2026-04-20 23:17:35 +02:00
|
|
|
await JS.InvokeVoidAsync("workTrackerPreferences.setBool", IncludePreviewPreferenceKey, includePreview);
|
2026-04-20 16:11:27 +02:00
|
|
|
await LoadSummary();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 22:10:19 +01:00
|
|
|
private async Task LoadSummary()
|
|
|
|
|
{
|
|
|
|
|
loading = true;
|
2026-04-20 16:11:27 +02:00
|
|
|
summary = await WorkDayService.GetMonthlySummaryAsync(currentMonth.Year, currentMonth.Month, includePreview);
|
2026-04-20 17:23:54 +02:00
|
|
|
timesheet = await WorkDayService.GetMonthlyTimesheetAsync(currentMonth.Year, currentMonth.Month, includePreview);
|
2026-03-17 22:10:19 +01:00
|
|
|
loading = false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 23:56:23 +02:00
|
|
|
private Task PreviousMonth()
|
2026-03-17 22:10:19 +01:00
|
|
|
{
|
|
|
|
|
currentMonth = currentMonth.AddMonths(-1);
|
2026-04-20 23:56:23 +02:00
|
|
|
Navigation.NavigateTo($"/summary/{currentMonth:yyyy-MM}");
|
|
|
|
|
return Task.CompletedTask;
|
2026-03-17 22:10:19 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-20 23:56:23 +02:00
|
|
|
private Task NextMonth()
|
2026-03-17 22:10:19 +01:00
|
|
|
{
|
|
|
|
|
currentMonth = currentMonth.AddMonths(1);
|
2026-04-20 23:56:23 +02:00
|
|
|
Navigation.NavigateTo($"/summary/{currentMonth:yyyy-MM}");
|
|
|
|
|
return Task.CompletedTask;
|
2026-03-17 22:10:19 +01:00
|
|
|
}
|
2026-04-20 16:11:27 +02:00
|
|
|
|
2026-04-20 17:23:54 +02:00
|
|
|
private void SetViewMode(SummaryViewMode mode)
|
|
|
|
|
{
|
|
|
|
|
viewMode = mode;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 10:45:44 +02:00
|
|
|
private string GetExcelDownloadUrl()
|
|
|
|
|
{
|
|
|
|
|
return $"/api/monthly-timesheet/{currentMonth.Year}/{currentMonth.Month}/excel?includePreview={includePreview.ToString().ToLowerInvariant()}";
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 17:23:54 +02:00
|
|
|
private static string GetDayHeader(DateOnly date)
|
|
|
|
|
{
|
|
|
|
|
return ItalianCulture.TextInfo.ToTitleCase(date.ToString("ddd", ItalianCulture));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 23:56:23 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 17:23:54 +02:00
|
|
|
private static string GetDayColumnClass(global::WorkTracker.Domain.MonthlyTimesheetDayModel day)
|
|
|
|
|
{
|
2026-04-23 00:11:00 +02:00
|
|
|
var classes = new List<string>();
|
|
|
|
|
|
|
|
|
|
if (day.Date == DateOnly.FromDateTime(DateTime.Today))
|
|
|
|
|
{
|
|
|
|
|
classes.Add("timesheet-summary-day-today");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 17:23:54 +02:00
|
|
|
if (day.IsWeekend || day.IsHoliday)
|
|
|
|
|
{
|
2026-04-23 00:11:00 +02:00
|
|
|
classes.Add("timesheet-summary-day-danger");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (day.IsClosure)
|
|
|
|
|
{
|
|
|
|
|
classes.Add("timesheet-summary-day-closure");
|
2026-04-20 17:23:54 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-23 00:11:00 +02:00
|
|
|
return string.Join(" ", classes);
|
2026-04-20 17:23:54 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string GetDayPopupClass(int index, int totalDays)
|
|
|
|
|
{
|
|
|
|
|
if (index == 0)
|
|
|
|
|
{
|
|
|
|
|
return "timesheet-summary-day-popup-left";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return index >= totalDays - 2 ? "timesheet-summary-day-popup-right" : string.Empty;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string FormatTimesheetValue(decimal? value, global::WorkTracker.Domain.MonthlyTimesheetValueFormat valueFormat)
|
|
|
|
|
{
|
|
|
|
|
if (!value.HasValue || value.Value <= 0m)
|
|
|
|
|
{
|
|
|
|
|
return string.Empty;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return valueFormat == global::WorkTracker.Domain.MonthlyTimesheetValueFormat.Days
|
|
|
|
|
? value.Value.ToString("0.##", ItalianCulture)
|
|
|
|
|
: FormatDecimalHours(value.Value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string FormatDecimalHours(decimal value)
|
|
|
|
|
{
|
2026-04-20 23:56:23 +02:00
|
|
|
return DurationFormatter.FormatHours(value, blankWhenZero: true);
|
2026-04-20 17:23:54 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-20 16:11:27 +02:00
|
|
|
private static string FormatHours(decimal value)
|
|
|
|
|
{
|
2026-04-20 23:56:23 +02:00
|
|
|
return DurationFormatter.FormatHours(value);
|
2026-04-20 16:11:27 +02:00
|
|
|
}
|
2026-04-20 17:23:54 +02:00
|
|
|
|
|
|
|
|
private enum SummaryViewMode
|
|
|
|
|
{
|
|
|
|
|
Cards,
|
|
|
|
|
Timesheet
|
|
|
|
|
}
|
2026-03-17 22:10:19 +01:00
|
|
|
}
|