feat: add yearly summary page with navigation and formatting improvements
All checks were successful
Publish Container / publish (push) Successful in 3m17s
All checks were successful
Publish Container / publish (push) Successful in 3m17s
This commit is contained in:
parent
0991128b30
commit
0d003903cf
12 changed files with 406 additions and 70 deletions
|
|
@ -60,6 +60,13 @@
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-item px-3">
|
||||||
|
<NavLink class="nav-link" href="yearly-summary" aria-label="Yearly Summary" title="Yearly Summary">
|
||||||
|
<span class="bi bi-table-nav-menu" aria-hidden="true"></span>
|
||||||
|
<span class="nav-label">Yearly Summary</span>
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="nav-item px-3">
|
<div class="nav-item px-3">
|
||||||
<NavLink class="nav-link" href="settings" aria-label="Settings" title="Settings">
|
<NavLink class="nav-link" href="settings" aria-label="Settings" title="Settings">
|
||||||
<span class="bi bi-gear-fill-nav-menu" aria-hidden="true"></span>
|
<span class="bi bi-gear-fill-nav-menu" aria-hidden="true"></span>
|
||||||
|
|
|
||||||
|
|
@ -462,10 +462,7 @@ else
|
||||||
|
|
||||||
private static string FormatHours(decimal value)
|
private static string FormatHours(decimal value)
|
||||||
{
|
{
|
||||||
var totalMinutes = (int)Math.Round(value * 60m, MidpointRounding.AwayFromZero);
|
return DurationFormatter.FormatHours(value);
|
||||||
var hours = totalMinutes / 60;
|
|
||||||
var minutes = totalMinutes % 60;
|
|
||||||
return $"{hours:00}:{minutes:00}";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class CalendarCell
|
private sealed class CalendarCell
|
||||||
|
|
|
||||||
|
|
@ -234,10 +234,7 @@ else
|
||||||
|
|
||||||
private static string FormatHours(decimal value)
|
private static string FormatHours(decimal value)
|
||||||
{
|
{
|
||||||
var totalMinutes = (int)Math.Round(value * 60m, MidpointRounding.AwayFromZero);
|
return DurationFormatter.FormatHours(value);
|
||||||
var hours = totalMinutes / 60;
|
|
||||||
var minutes = totalMinutes % 60;
|
|
||||||
return $"{hours:00}:{minutes:00}";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class CalendarDayRow
|
private sealed class CalendarDayRow
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,15 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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="col-12 col-md-6 col-xl-4">
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
@using System.Globalization
|
@using System.Globalization
|
||||||
@inject global::WorkTracker.Services.WorkDays.IWorkDayService WorkDayService
|
@inject global::WorkTracker.Services.WorkDays.IWorkDayService WorkDayService
|
||||||
@inject IJSRuntime JS
|
@inject IJSRuntime JS
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
<PageTitle>Monthly Summary</PageTitle>
|
<PageTitle>Monthly Summary</PageTitle>
|
||||||
|
|
||||||
|
|
@ -15,6 +16,7 @@
|
||||||
<button class="btn btn-outline-secondary btn-sm" @onclick="PreviousMonth">« Prev</button>
|
<button class="btn btn-outline-secondary btn-sm" @onclick="PreviousMonth">« Prev</button>
|
||||||
<h2 class="h5 mb-0">@currentMonth.ToString("MMMM yyyy")</h2>
|
<h2 class="h5 mb-0">@currentMonth.ToString("MMMM yyyy")</h2>
|
||||||
<button class="btn btn-outline-secondary btn-sm" @onclick="NextMonth">Next »</button>
|
<button class="btn btn-outline-secondary btn-sm" @onclick="NextMonth">Next »</button>
|
||||||
|
<a class="btn btn-outline-primary btn-sm ms-auto" href="yearly-summary/@currentMonth.Year">Yearly Summary</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-check mb-3">
|
<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 global::WorkTracker.Domain.MonthlyTimesheetModel? timesheet;
|
||||||
private SummaryViewMode viewMode = SummaryViewMode.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 = ParseCurrentMonth();
|
||||||
{
|
|
||||||
currentMonth = new DateOnly(parsed.Year, parsed.Month, 1);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
currentMonth = new DateOnly(DateTime.Today.Year, DateTime.Today.Month, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
await LoadSummary();
|
await LoadSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -264,16 +258,18 @@ else if (viewMode == SummaryViewMode.Timesheet && timesheet is not null)
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task PreviousMonth()
|
private Task PreviousMonth()
|
||||||
{
|
{
|
||||||
currentMonth = currentMonth.AddMonths(-1);
|
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);
|
currentMonth = currentMonth.AddMonths(1);
|
||||||
await LoadSummary();
|
Navigation.NavigateTo($"/summary/{currentMonth:yyyy-MM}");
|
||||||
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SetViewMode(SummaryViewMode mode)
|
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));
|
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)
|
private static string GetDayColumnClass(global::WorkTracker.Domain.MonthlyTimesheetDayModel day)
|
||||||
{
|
{
|
||||||
if (day.IsWeekend || day.IsHoliday)
|
if (day.IsWeekend || day.IsHoliday)
|
||||||
|
|
@ -320,15 +326,12 @@ else if (viewMode == SummaryViewMode.Timesheet && timesheet is not null)
|
||||||
|
|
||||||
private static string FormatDecimalHours(decimal value)
|
private static string FormatDecimalHours(decimal value)
|
||||||
{
|
{
|
||||||
return value.ToString("0.##", ItalianCulture);
|
return DurationFormatter.FormatHours(value, blankWhenZero: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatHours(decimal value)
|
private static string FormatHours(decimal value)
|
||||||
{
|
{
|
||||||
var totalMinutes = (int)Math.Round(value * 60m, MidpointRounding.AwayFromZero);
|
return DurationFormatter.FormatHours(value);
|
||||||
var hours = totalMinutes / 60;
|
|
||||||
var minutes = totalMinutes % 60;
|
|
||||||
return $"{hours:00}:{minutes:00}";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum SummaryViewMode
|
private enum SummaryViewMode
|
||||||
|
|
|
||||||
|
|
@ -383,23 +383,13 @@ else
|
||||||
return false;
|
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
|
private static string FormatSignedHours(decimal value) => DurationFormatter.FormatSignedHours(value);
|
||||||
{
|
|
||||||
> 0 => $"+{FormatDurationHours(value)}",
|
|
||||||
< 0 => $"-{FormatDurationHours(Math.Abs(value))}",
|
|
||||||
_ => "00:00"
|
|
||||||
};
|
|
||||||
|
|
||||||
private static string FormatDurationHours(decimal value)
|
private static string FormatDurationHours(decimal value)
|
||||||
{
|
{
|
||||||
var totalMinutes = (int)Math.Round(value * 60m, MidpointRounding.AwayFromZero);
|
return DurationFormatter.FormatHours(value);
|
||||||
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()
|
protected override void OnParametersSet()
|
||||||
|
|
|
||||||
205
Components/Pages/YearlySummary.razor
Normal file
205
Components/Pages/YearlySummary.razor
Normal 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">« Prev</button>
|
||||||
|
<h2 class="h5 mb-0">@currentYear</h2>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" @onclick="NextYear">Next »</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)}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
@using WorkTracker
|
@using WorkTracker
|
||||||
@using WorkTracker.Components
|
@using WorkTracker.Components
|
||||||
@using WorkTracker.Domain
|
@using WorkTracker.Domain
|
||||||
|
@using WorkTracker.Formatting
|
||||||
@using WorkTracker.Services.Festivities
|
@using WorkTracker.Services.Festivities
|
||||||
@using WorkTracker.Services.Settings
|
@using WorkTracker.Services.Settings
|
||||||
@using WorkTracker.Services.WorkDays
|
@using WorkTracker.Services.WorkDays
|
||||||
|
|
|
||||||
37
Formatting/DurationFormatter.cs
Normal file
37
Formatting/DurationFormatter.cs
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
namespace WorkTracker.Formatting;
|
||||||
|
|
||||||
|
public static class DurationFormatter
|
||||||
|
{
|
||||||
|
public static string FormatHours(decimal value, bool blankWhenZero = false)
|
||||||
|
{
|
||||||
|
var totalMinutes = (int)Math.Round(value * 60m, MidpointRounding.AwayFromZero);
|
||||||
|
if (totalMinutes == 0)
|
||||||
|
{
|
||||||
|
return blankWhenZero ? string.Empty : "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
var sign = totalMinutes < 0 ? "-" : string.Empty;
|
||||||
|
totalMinutes = Math.Abs(totalMinutes);
|
||||||
|
|
||||||
|
var hours = totalMinutes / 60;
|
||||||
|
var minutes = totalMinutes % 60;
|
||||||
|
return minutes == 0
|
||||||
|
? $"{sign}{hours}"
|
||||||
|
: $"{sign}{hours}:{minutes:00}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string FormatSignedHours(decimal value)
|
||||||
|
{
|
||||||
|
if (value > 0m)
|
||||||
|
{
|
||||||
|
return $"+{FormatHours(value)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value < 0m)
|
||||||
|
{
|
||||||
|
return $"-{FormatHours(Math.Abs(value))}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -180,36 +180,20 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
||||||
public async Task<MonthlySummaryModel> GetMonthlySummaryAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default)
|
public async Task<MonthlySummaryModel> GetMonthlySummaryAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var from = new DateOnly(year, month, 1);
|
var from = new DateOnly(year, month, 1);
|
||||||
var to = from.AddMonths(1).AddDays(-1);
|
return await BuildMonthlySummaryAsync(from, includePreview, cancellationToken);
|
||||||
var days = await GetRangeAsync(from, to, cancellationToken);
|
}
|
||||||
|
|
||||||
var includedUnits = days
|
public async Task<IReadOnlyList<MonthlySummaryModel>> GetYearlySummaryAsync(int year, bool includePreview, CancellationToken cancellationToken = default)
|
||||||
.SelectMany(day => day.WorkUnits.Where(unit => includePreview || !unit.IsPreview).Select(unit => new { day.Date, Unit = unit }))
|
{
|
||||||
.ToList();
|
var summaries = new List<MonthlySummaryModel>(12);
|
||||||
|
|
||||||
var previewUnits = days
|
for (var month = 1; month <= 12; month++)
|
||||||
.SelectMany(day => day.WorkUnits.Where(unit => unit.IsPreview).Select(unit => new { day.Date, Unit = unit }))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return new MonthlySummaryModel
|
|
||||||
{
|
{
|
||||||
Year = year,
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
Month = month,
|
summaries.Add(await BuildMonthlySummaryAsync(new DateOnly(year, month, 1), includePreview, cancellationToken));
|
||||||
TotalWorkedHours = includedUnits.Sum(item => item.Unit.ManualWorkedHours),
|
}
|
||||||
TotalPreviewWorkedHours = previewUnits.Sum(item => item.Unit.ManualWorkedHours),
|
|
||||||
CountedWorkUnits = includedUnits.Count,
|
return summaries;
|
||||||
PreviewWorkUnits = previewUnits.Count,
|
|
||||||
OfficeDays = includedUnits.Where(item => item.Unit.Location == WorkUnitLocation.Office).Select(item => item.Date).Distinct().Count(),
|
|
||||||
HomeDays = includedUnits.Where(item => item.Unit.Location == WorkUnitLocation.Home).Select(item => item.Date).Distinct().Count(),
|
|
||||||
HolidayDays = CountDaysWithEvent(days, CalendarEventType.Holiday),
|
|
||||||
SickDays = CountDaysWithEvent(days, CalendarEventType.Illness),
|
|
||||||
DaysOff = CountDaysWithEvent(days, CalendarEventType.DayOff),
|
|
||||||
ClosureDays = CountDaysWithEvent(days, CalendarEventType.Closure),
|
|
||||||
TotalHoursOff = days.Sum(day => GetHoursOff(day, includePreview)),
|
|
||||||
TotalGrossIncome = includedUnits.Sum(item => item.Unit.GrossIncome),
|
|
||||||
TotalNetIncome = includedUnits.Sum(item => item.Unit.NetIncome),
|
|
||||||
TotalWorkingDays = includedUnits.Select(item => item.Date).Distinct().Count()
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<MonthlyTimesheetModel> GetMonthlyTimesheetAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default)
|
public async Task<MonthlyTimesheetModel> GetMonthlyTimesheetAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default)
|
||||||
|
|
@ -480,6 +464,40 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
||||||
return days.Count(day => day.CalendarEvents.Any(calendarEvent => calendarEvent.EventType == eventType));
|
return days.Count(day => day.CalendarEvents.Any(calendarEvent => calendarEvent.EventType == eventType));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<MonthlySummaryModel> BuildMonthlySummaryAsync(DateOnly from, bool includePreview, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var to = from.AddMonths(1).AddDays(-1);
|
||||||
|
var days = await GetRangeAsync(from, to, cancellationToken);
|
||||||
|
|
||||||
|
var includedUnits = days
|
||||||
|
.SelectMany(day => day.WorkUnits.Where(unit => includePreview || !unit.IsPreview).Select(unit => new { day.Date, Unit = unit }))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var previewUnits = days
|
||||||
|
.SelectMany(day => day.WorkUnits.Where(unit => unit.IsPreview).Select(unit => new { day.Date, Unit = unit }))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new MonthlySummaryModel
|
||||||
|
{
|
||||||
|
Year = from.Year,
|
||||||
|
Month = from.Month,
|
||||||
|
TotalWorkedHours = includedUnits.Sum(item => item.Unit.ManualWorkedHours),
|
||||||
|
TotalPreviewWorkedHours = previewUnits.Sum(item => item.Unit.ManualWorkedHours),
|
||||||
|
CountedWorkUnits = includedUnits.Count,
|
||||||
|
PreviewWorkUnits = previewUnits.Count,
|
||||||
|
OfficeDays = includedUnits.Where(item => item.Unit.Location == WorkUnitLocation.Office).Select(item => item.Date).Distinct().Count(),
|
||||||
|
HomeDays = includedUnits.Where(item => item.Unit.Location == WorkUnitLocation.Home).Select(item => item.Date).Distinct().Count(),
|
||||||
|
HolidayDays = CountDaysWithEvent(days, CalendarEventType.Holiday),
|
||||||
|
SickDays = CountDaysWithEvent(days, CalendarEventType.Illness),
|
||||||
|
DaysOff = CountDaysWithEvent(days, CalendarEventType.DayOff),
|
||||||
|
ClosureDays = CountDaysWithEvent(days, CalendarEventType.Closure),
|
||||||
|
TotalHoursOff = days.Sum(day => GetHoursOff(day, includePreview)),
|
||||||
|
TotalGrossIncome = includedUnits.Sum(item => item.Unit.GrossIncome),
|
||||||
|
TotalNetIncome = includedUnits.Sum(item => item.Unit.NetIncome),
|
||||||
|
TotalWorkingDays = includedUnits.Select(item => item.Date).Distinct().Count()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static MonthlyTimesheetDaySummary CreateTimesheetDaySummary(WorkDayDocument? day, DateOnly date, bool includePreview, decimal defaultStandardHours)
|
private static MonthlyTimesheetDaySummary CreateTimesheetDaySummary(WorkDayDocument? day, DateOnly date, bool includePreview, decimal defaultStandardHours)
|
||||||
{
|
{
|
||||||
var includedUnits = day?.WorkUnits.Where(unit => includePreview || !unit.IsPreview).ToList() ?? [];
|
var includedUnits = day?.WorkUnits.Where(unit => includePreview || !unit.IsPreview).ToList() ?? [];
|
||||||
|
|
@ -585,9 +603,7 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
||||||
|
|
||||||
private static string FormatCompactHours(decimal value)
|
private static string FormatCompactHours(decimal value)
|
||||||
{
|
{
|
||||||
return value == decimal.Truncate(value)
|
return Formatting.DurationFormatter.FormatHours(value);
|
||||||
? value.ToString("0")
|
|
||||||
: value.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static decimal GetNightHours(WorkUnitDocument unit)
|
private static decimal GetNightHours(WorkUnitDocument unit)
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ public interface IWorkDayService
|
||||||
|
|
||||||
Task<MonthlySummaryModel> GetMonthlySummaryAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default);
|
Task<MonthlySummaryModel> GetMonthlySummaryAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<MonthlySummaryModel>> GetYearlySummaryAsync(int year, bool includePreview, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<MonthlyTimesheetModel> GetMonthlyTimesheetAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default);
|
Task<MonthlyTimesheetModel> GetMonthlyTimesheetAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<int> GenerateMonthlyPreviewWorkUnitsAsync(int year, int month, CancellationToken cancellationToken = default);
|
Task<int> GenerateMonthlyPreviewWorkUnitsAsync(int year, int month, CancellationToken cancellationToken = default);
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,10 @@ main {
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M1 11a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1v-3zm5-4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7zm5-5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V2z'/%3E%3C/svg%3E");
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M1 11a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1v-3zm5-4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7zm5-5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V2z'/%3E%3C/svg%3E");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bi-table-nav-menu {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2zm15 2H1v3h14V4zm0 4H1v3h14V8zm0 4H1v2a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
|
|
@ -861,3 +865,71 @@ h1:focus {
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Yearly summary */
|
||||||
|
.yearly-summary-card {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yearly-summary-table {
|
||||||
|
min-width: 52rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yearly-summary-table thead th,
|
||||||
|
.yearly-summary-table tfoot th,
|
||||||
|
.yearly-summary-table tfoot td {
|
||||||
|
background-color: var(--wt-summary-head-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yearly-summary-table thead th {
|
||||||
|
white-space: normal;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yearly-summary-table th,
|
||||||
|
.yearly-summary-table td {
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: middle;
|
||||||
|
padding: 0.45rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yearly-summary-sticky-column {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background-color: var(--wt-summary-sticky-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yearly-summary-header-cell {
|
||||||
|
width: 5.2rem;
|
||||||
|
min-width: 5.2rem;
|
||||||
|
max-width: 5.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yearly-summary-table thead .yearly-summary-sticky-column,
|
||||||
|
.yearly-summary-table tfoot .yearly-summary-sticky-column {
|
||||||
|
z-index: 3;
|
||||||
|
background-color: var(--wt-summary-head-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yearly-summary-table tbody tr:nth-child(odd) td,
|
||||||
|
.yearly-summary-table tbody tr:nth-child(odd) .yearly-summary-sticky-column {
|
||||||
|
background-color: var(--wt-summary-row-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yearly-summary-total-row th,
|
||||||
|
.yearly-summary-total-row td {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.yearly-summary-table {
|
||||||
|
min-width: 44rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yearly-summary-header-cell {
|
||||||
|
width: 4.5rem;
|
||||||
|
min-width: 4.5rem;
|
||||||
|
max-width: 4.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue