2026-03-17 22:10:19 +01:00
|
|
|
@page "/grid"
|
|
|
|
|
@page "/grid/{YearMonth}"
|
|
|
|
|
@attribute [Authorize]
|
|
|
|
|
@rendermode InteractiveServer
|
|
|
|
|
|
|
|
|
|
@inject IWorkDayService WorkDayService
|
|
|
|
|
@inject IItalianFestivitySource FestivitySource
|
|
|
|
|
@inject NavigationManager Navigation
|
|
|
|
|
|
|
|
|
|
<PageTitle>Grid View</PageTitle>
|
|
|
|
|
|
|
|
|
|
<h1>Grid View</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">@currentDate.ToString("MMMM yyyy")</h2>
|
|
|
|
|
<button class="btn btn-outline-secondary btn-sm" @onclick="NextMonth">Next »</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
@if (loading)
|
|
|
|
|
{
|
|
|
|
|
<p><em>Loading...</em></p>
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
<div class="table-responsive">
|
2026-04-20 22:58:25 +02:00
|
|
|
<table class="table table-sm table-bordered align-middle grid-view-table">
|
2026-03-17 22:10:19 +01:00
|
|
|
<thead class="table-dark">
|
|
|
|
|
<tr>
|
|
|
|
|
<th>Date</th>
|
|
|
|
|
<th>Day</th>
|
2026-04-20 16:11:27 +02:00
|
|
|
<th>Work Units</th>
|
|
|
|
|
<th>Calendar Events</th>
|
|
|
|
|
<th class="text-end">Counted</th>
|
|
|
|
|
<th class="text-end">Preview</th>
|
2026-03-17 22:10:19 +01:00
|
|
|
<th class="text-end">Off</th>
|
|
|
|
|
<th class="text-end">Gross €</th>
|
|
|
|
|
<th class="text-end">Net €</th>
|
|
|
|
|
<th></th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
@foreach (var row in calendarDays)
|
|
|
|
|
{
|
|
|
|
|
<tr class="@GetRowClass(row)">
|
|
|
|
|
<td>@row.Date.ToString("dd")</td>
|
|
|
|
|
<td>@row.Date.ToString("ddd")</td>
|
|
|
|
|
<td>
|
2026-04-20 16:11:27 +02:00
|
|
|
@if (row.Entry?.WorkUnits.Count > 0)
|
|
|
|
|
{
|
|
|
|
|
@foreach (var unit in row.Entry.WorkUnits)
|
|
|
|
|
{
|
|
|
|
|
<div class="small mb-1">@unit.Label: @FormatTimeRange(unit.StartTime, unit.EndTime) (@FormatHours(unit.ManualWorkedHours)@(unit.IsPreview ? ", preview" : ""))</div>
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
<span class="text-muted">—</span>
|
|
|
|
|
}
|
|
|
|
|
</td>
|
|
|
|
|
<td>
|
|
|
|
|
@if (row.Entry?.CalendarEvents.Count > 0)
|
|
|
|
|
{
|
|
|
|
|
@foreach (var calendarEvent in row.Entry.CalendarEvents)
|
|
|
|
|
{
|
2026-04-22 11:07:30 +02:00
|
|
|
<div class="small mb-1">@CalendarEventFormatter.GetEventTypeName(calendarEvent.EventType): @CalendarEventFormatter.GetDisplayDescription(calendarEvent)</div>
|
2026-04-20 16:11:27 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
<span class="text-muted">—</span>
|
|
|
|
|
}
|
|
|
|
|
</td>
|
|
|
|
|
<td class="text-end">@FormatHours(GetCountedHours(row))</td>
|
|
|
|
|
<td class="text-end">@FormatHours(GetPreviewHours(row))</td>
|
|
|
|
|
<td class="text-end">@FormatHours(GetHoursOff(row))</td>
|
|
|
|
|
<td class="text-end">@GetGrossIncome(row).ToString("N2")</td>
|
|
|
|
|
<td class="text-end">@GetNetIncome(row).ToString("N2")</td>
|
|
|
|
|
<td>
|
|
|
|
|
<div class="d-flex gap-2">
|
|
|
|
|
<a href="work-unit/@row.Date.ToString("yyyy-MM-dd")" class="btn btn-sm btn-outline-primary">Unit</a>
|
|
|
|
|
<a href="calendar-event/@row.Date.ToString("yyyy-MM-dd")" class="btn btn-sm btn-outline-secondary">Event</a>
|
|
|
|
|
</div>
|
2026-03-17 22:10:19 +01:00
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@code {
|
|
|
|
|
[Parameter] public string? YearMonth { get; set; }
|
|
|
|
|
|
|
|
|
|
private DateOnly currentDate;
|
|
|
|
|
private bool loading = true;
|
|
|
|
|
private List<CalendarDayRow> calendarDays = [];
|
|
|
|
|
private IReadOnlyCollection<DateOnly> festivities = [];
|
|
|
|
|
|
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
|
|
|
{
|
|
|
|
|
if (!string.IsNullOrEmpty(YearMonth) && DateTime.TryParseExact(YearMonth, "yyyy-MM", null, System.Globalization.DateTimeStyles.None, out var parsed))
|
|
|
|
|
{
|
|
|
|
|
currentDate = new DateOnly(parsed.Year, parsed.Month, 1);
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
currentDate = new DateOnly(DateTime.Today.Year, DateTime.Today.Month, 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await LoadMonth();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task LoadMonth()
|
|
|
|
|
{
|
|
|
|
|
loading = true;
|
|
|
|
|
festivities = FestivitySource.GetFestivities(currentDate.Year);
|
|
|
|
|
|
|
|
|
|
var from = currentDate;
|
|
|
|
|
var to = currentDate.AddMonths(1).AddDays(-1);
|
|
|
|
|
var entries = await WorkDayService.GetRangeAsync(from, to);
|
|
|
|
|
var lookup = entries.ToDictionary(e => e.Date);
|
|
|
|
|
|
|
|
|
|
calendarDays = [];
|
|
|
|
|
for (var d = from; d <= to; d = d.AddDays(1))
|
|
|
|
|
{
|
|
|
|
|
calendarDays.Add(new CalendarDayRow
|
|
|
|
|
{
|
|
|
|
|
Date = d,
|
|
|
|
|
IsWeekend = d.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday,
|
|
|
|
|
IsFestivity = festivities.Contains(d),
|
|
|
|
|
Entry = lookup.GetValueOrDefault(d)
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loading = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task PreviousMonth()
|
|
|
|
|
{
|
|
|
|
|
currentDate = currentDate.AddMonths(-1);
|
|
|
|
|
await LoadMonth();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task NextMonth()
|
|
|
|
|
{
|
|
|
|
|
currentDate = currentDate.AddMonths(1);
|
|
|
|
|
await LoadMonth();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private string GetRowClass(CalendarDayRow row)
|
|
|
|
|
{
|
2026-04-20 22:58:25 +02:00
|
|
|
if (row.IsWeekend || row.IsFestivity) return "grid-row-weekend";
|
|
|
|
|
if (row.Entry is null) return string.Empty;
|
2026-04-20 16:11:27 +02:00
|
|
|
if (row.Entry.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.Holiday))
|
|
|
|
|
{
|
2026-04-20 22:58:25 +02:00
|
|
|
return "grid-row-holiday";
|
2026-04-20 16:11:27 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (row.Entry.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.Closure))
|
|
|
|
|
{
|
2026-04-20 22:58:25 +02:00
|
|
|
return "grid-row-closure";
|
2026-04-20 16:11:27 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (row.Entry.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.Illness))
|
|
|
|
|
{
|
2026-04-20 22:58:25 +02:00
|
|
|
return "grid-row-illness";
|
2026-04-20 16:11:27 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (row.Entry.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.DayOff))
|
2026-03-17 22:10:19 +01:00
|
|
|
{
|
2026-04-20 22:58:25 +02:00
|
|
|
return "grid-row-dayoff";
|
2026-04-20 16:11:27 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (row.Entry.WorkUnits.Any(entry => entry.Location == WorkUnitLocation.Home))
|
|
|
|
|
{
|
2026-04-20 22:58:25 +02:00
|
|
|
return "grid-row-home";
|
2026-04-20 16:11:27 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return string.Empty;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static decimal GetCountedHours(CalendarDayRow row)
|
|
|
|
|
{
|
|
|
|
|
return row.Entry?.WorkUnits.Where(unit => !unit.IsPreview).Sum(unit => unit.ManualWorkedHours) ?? 0m;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static decimal GetPreviewHours(CalendarDayRow row)
|
|
|
|
|
{
|
|
|
|
|
return row.Entry?.WorkUnits.Where(unit => unit.IsPreview).Sum(unit => unit.ManualWorkedHours) ?? 0m;
|
2026-03-17 22:10:19 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-20 16:11:27 +02:00
|
|
|
private static decimal GetHoursOff(CalendarDayRow row)
|
2026-03-17 22:10:19 +01:00
|
|
|
{
|
2026-04-20 16:11:27 +02:00
|
|
|
if (row.Entry is null || row.Entry.WorkUnits.Count == 0)
|
|
|
|
|
{
|
|
|
|
|
return 0m;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var countedUnits = row.Entry.WorkUnits.Where(unit => !unit.IsPreview).ToList();
|
|
|
|
|
if (countedUnits.Count == 0)
|
|
|
|
|
{
|
|
|
|
|
return 0m;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var standardHours = countedUnits[0].CoeffSnapshot.StandardWorkHoursPerDay;
|
|
|
|
|
return Math.Max(0m, standardHours - countedUnits.Sum(unit => unit.ManualWorkedHours));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static decimal GetGrossIncome(CalendarDayRow row)
|
|
|
|
|
{
|
|
|
|
|
return row.Entry?.WorkUnits.Where(unit => !unit.IsPreview).Sum(unit => unit.GrossIncome) ?? 0m;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static decimal GetNetIncome(CalendarDayRow row)
|
|
|
|
|
{
|
|
|
|
|
return row.Entry?.WorkUnits.Where(unit => !unit.IsPreview).Sum(unit => unit.NetIncome) ?? 0m;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string FormatTimeRange(TimeOnly? startTime, TimeOnly? endTime)
|
|
|
|
|
{
|
|
|
|
|
if (startTime.HasValue && endTime.HasValue)
|
|
|
|
|
{
|
|
|
|
|
return $"{startTime:HH:mm}-{endTime:HH:mm}";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (startTime.HasValue)
|
|
|
|
|
{
|
|
|
|
|
return startTime.Value.ToString("HH:mm");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return "No time range";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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-03-17 22:10:19 +01:00
|
|
|
|
|
|
|
|
private sealed class CalendarDayRow
|
|
|
|
|
{
|
|
|
|
|
public DateOnly Date { get; set; }
|
|
|
|
|
public bool IsWeekend { get; set; }
|
|
|
|
|
public bool IsFestivity { get; set; }
|
|
|
|
|
public WorkDayDocument? Entry { get; set; }
|
|
|
|
|
}
|
|
|
|
|
}
|