WorkTracker/Components/Pages/CalendarView.razor

479 lines
19 KiB
Text
Raw Normal View History

@page "/calendar"
@page "/calendar/{YearMonth}"
@attribute [Authorize]
@rendermode InteractiveServer
@inject IWorkDayService WorkDayService
@inject IItalianFestivitySource FestivitySource
@inject NavigationManager Navigation
@inject IJSRuntime JS
<PageTitle>Calendar</PageTitle>
<div class="calendar-page">
<h1>Calendar</h1>
<div class="d-flex align-items-center gap-2 mb-3">
<button class="btn btn-outline-secondary btn-sm" @onclick="PreviousMonth">&laquo; Prev</button>
<h2 class="h5 mb-0">@firstOfMonth.ToString("MMMM yyyy")</h2>
<button class="btn btn-outline-secondary btn-sm" @onclick="NextMonth">Next &raquo;</button>
<button class="btn btn-primary btn-sm ms-auto" @onclick="GeneratePreviewWorkUnitsAsync">Generate Preview Units</button>
</div>
@if (!string.IsNullOrWhiteSpace(statusMessage))
{
<div class="alert alert-info py-2">@statusMessage</div>
}
@if (loading)
{
<p><em>Loading...</em></p>
}
else
{
<div class="table-responsive">
<table class="table table-bordered calendar-table">
<thead class="table-dark">
<tr>
<th>Mon</th>
<th>Tue</th>
<th>Wed</th>
<th>Thu</th>
<th>Fri</th>
<th>Sat</th>
<th>Sun</th>
</tr>
</thead>
<tbody>
@foreach (var week in weeks)
{
<tr>
@foreach (var cell in week)
{
@if (cell is null)
{
<td class="calendar-cell calendar-cell-empty"></td>
}
else
{
<td class="calendar-cell @GetCellClass(cell) @(IsToday(cell.Date) ? "calendar-cell-today" : string.Empty) @(IsActiveCell(cell.Date) ? "calendar-cell-active" : string.Empty)" @onclick="() => TogglePopup(cell.Date)" role="button">
<div class="calendar-day-number">@cell.Date.Day</div>
@foreach (var workUnit in cell.Entry?.WorkUnits ?? [])
{
<button type="button" class="calendar-item calendar-item-work @GetWorkUnitClass(workUnit)" @onclick:stopPropagation="true" @onclick="() => OpenWorkUnit(cell.Date, workUnit.Id)">
<span>@workUnit.Label</span>
<span>@FormatWorkUnit(workUnit)</span>
</button>
}
@foreach (var calendarEvent in cell.Entry?.CalendarEvents ?? [])
{
<button type="button" class="calendar-item calendar-item-event @GetCalendarEventClass(calendarEvent)" @onclick:stopPropagation="true" @onclick="() => OpenCalendarEvent(cell.Date, calendarEvent.Id)">
<span>@calendarEvent.Description</span>
@if (calendarEvent.StartTime.HasValue)
{
<span>@calendarEvent.StartTime.Value.ToString("HH:mm")</span>
}
</button>
}
<div class="calendar-day-total">@FormatHours(GetDayTotalHours(cell.Entry, includePreviewTotals))</div>
@if (IsActiveCell(cell.Date))
{
<div class="calendar-popup @GetPopupClass(cell)" @onclick:stopPropagation="true">
<div class="d-flex align-items-start justify-content-between gap-2 mb-2">
<div>
<div class="fw-semibold">@cell.Date.ToString("dddd d MMMM")</div>
<div class="small text-muted">Select an existing entry or create a new one.</div>
</div>
<button type="button" class="btn-close btn-sm" aria-label="Close" @onclick="ClosePopup"></button>
</div>
<div class="calendar-popup-section">
@if ((cell.Entry?.WorkUnits.Count ?? 0) == 0 && (cell.Entry?.CalendarEvents.Count ?? 0) == 0)
{
<div class="small text-muted">No entries for this day.</div>
}
@foreach (var workUnit in cell.Entry?.WorkUnits ?? [])
{
<button type="button" class="calendar-popup-link" @onclick="() => OpenWorkUnit(cell.Date, workUnit.Id)">
Work unit: @workUnit.Label (@FormatWorkUnit(workUnit))
</button>
}
@foreach (var calendarEvent in cell.Entry?.CalendarEvents ?? [])
{
<button type="button" class="calendar-popup-link" @onclick="() => OpenCalendarEvent(cell.Date, calendarEvent.Id)">
Calendar event: @calendarEvent.Description
</button>
}
</div>
<div class="d-flex gap-2 mt-3">
<button type="button" class="btn btn-sm btn-primary" @onclick="() => CreateWorkUnit(cell.Date)">New Work Unit</button>
<button type="button" class="btn btn-sm btn-outline-primary" @onclick="() => CreateCalendarEvent(cell.Date)">New Calendar Event</button>
</div>
</div>
}
</td>
}
}
</tr>
}
</tbody>
</table>
</div>
<div class="mt-3">
<h3 class="h6">Legend</h3>
<div class="d-flex flex-wrap gap-2">
<span class="badge calendar-legend-work">Office work unit</span>
<span class="badge calendar-legend-home">Home work unit</span>
<span class="badge calendar-legend-preview">Preview work unit</span>
<span class="badge bg-warning text-dark">Closure</span>
<span class="badge bg-secondary">Day off</span>
<span class="badge bg-danger">Holiday</span>
</div>
</div>
@if (monthTotals is not null)
{
<div class="calendar-month-summary mt-4">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3 mb-3">
<h3 class="h6 mb-0">Month Totals</h3>
<div class="form-check mb-0">
<input id="calendar-include-preview" type="checkbox" class="form-check-input" checked="@includePreviewTotals" @onchange="OnIncludePreviewTotalsChanged" />
<label class="form-check-label" for="calendar-include-preview">Include preview work units</label>
</div>
</div>
<div class="row g-3">
<div class="col-6 col-md-4 col-xl-2">
<div class="card text-center h-100">
<div class="card-body">
<div class="text-muted small">Worked Hours</div>
<div class="fs-4 fw-bold">@FormatHours(monthTotals.TotalWorkedHours)</div>
</div>
</div>
</div>
<div class="col-6 col-md-4 col-xl-2">
<div class="card text-center h-100">
<div class="card-body">
<div class="text-muted small">Counted Units</div>
<div class="fs-4 fw-bold">@monthTotals.CountedWorkUnits</div>
</div>
</div>
</div>
<div class="col-6 col-md-4 col-xl-2">
<div class="card text-center h-100">
<div class="card-body">
<div class="text-muted small">Hours Off</div>
<div class="fs-4 fw-bold">@FormatHours(monthTotals.TotalHoursOff)</div>
</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-4 fw-bold text-success">€@monthTotals.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-4 fw-bold text-primary">€@monthTotals.TotalNetIncome.ToString("N2")</div>
</div>
</div>
</div>
</div>
</div>
}
}
</div>
@code {
[Parameter] public string? YearMonth { get; set; }
private const string IncludePreviewPreferenceKey = "worktracker.includePreviewWorkUnits";
private DateOnly firstOfMonth;
private bool loading = true;
private List<CalendarCell?[]> weeks = [];
private IReadOnlyCollection<DateOnly> festivities = [];
private DateOnly? activeDate;
private bool includePreviewTotals;
private MonthlySummaryModel? monthTotals;
private string? statusMessage;
protected override async Task OnInitializedAsync()
{
if (!string.IsNullOrEmpty(YearMonth) && DateTime.TryParseExact(YearMonth, "yyyy-MM", null, System.Globalization.DateTimeStyles.None, out var parsed))
{
firstOfMonth = new DateOnly(parsed.Year, parsed.Month, 1);
}
else
{
firstOfMonth = new DateOnly(DateTime.Today.Year, DateTime.Today.Month, 1);
}
await LoadMonth();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
var savedIncludePreview = await JS.InvokeAsync<bool?>("workTrackerPreferences.getBool", IncludePreviewPreferenceKey);
if (savedIncludePreview.HasValue && savedIncludePreview.Value != includePreviewTotals)
{
includePreviewTotals = savedIncludePreview.Value;
await LoadMonth();
await InvokeAsync(StateHasChanged);
}
}
private async Task LoadMonth()
{
loading = true;
activeDate = null;
festivities = FestivitySource.GetFestivities(firstOfMonth.Year);
var lastDay = firstOfMonth.AddMonths(1).AddDays(-1);
var entries = await WorkDayService.GetRangeAsync(firstOfMonth, lastDay);
var lookup = entries.ToDictionary(e => e.Date);
monthTotals = await WorkDayService.GetMonthlySummaryAsync(firstOfMonth.Year, firstOfMonth.Month, includePreviewTotals);
// Build calendar grid (ISO weeks: Monday = 0)
weeks = [];
var currentWeek = new CalendarCell?[7];
var dayOfWeek = ((int)firstOfMonth.DayOfWeek + 6) % 7; // Mon=0
for (var d = firstOfMonth; d <= lastDay; d = d.AddDays(1))
{
currentWeek[dayOfWeek] = new CalendarCell
{
Date = d,
ColumnIndex = dayOfWeek,
IsWeekend = d.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday,
IsFestivity = festivities.Contains(d),
Entry = lookup.GetValueOrDefault(d)
};
dayOfWeek++;
if (dayOfWeek == 7)
{
weeks.Add(currentWeek);
currentWeek = new CalendarCell?[7];
dayOfWeek = 0;
}
}
if (dayOfWeek > 0)
{
weeks.Add(currentWeek);
}
loading = false;
}
private async Task OnIncludePreviewTotalsChanged(ChangeEventArgs e)
{
includePreviewTotals = e.Value is bool value && value;
await JS.InvokeVoidAsync("workTrackerPreferences.setBool", IncludePreviewPreferenceKey, includePreviewTotals);
await LoadMonth();
}
private async Task PreviousMonth()
{
firstOfMonth = firstOfMonth.AddMonths(-1);
statusMessage = null;
await LoadMonth();
}
private async Task NextMonth()
{
firstOfMonth = firstOfMonth.AddMonths(1);
statusMessage = null;
await LoadMonth();
}
private void TogglePopup(DateOnly date)
{
activeDate = activeDate == date ? null : date;
}
private void ClosePopup()
{
activeDate = null;
}
private static string GetPopupClass(CalendarCell cell)
{
if (cell.ColumnIndex == 0)
{
return "calendar-popup-left";
}
return cell.ColumnIndex >= 5 ? "calendar-popup-right" : string.Empty;
}
private bool IsActiveCell(DateOnly date) => activeDate == date;
private static bool IsToday(DateOnly date) => date == DateOnly.FromDateTime(DateTime.Today);
private void CreateWorkUnit(DateOnly date)
{
Navigation.NavigateTo($"/work-unit/{date:yyyy-MM-dd}");
}
private void CreateCalendarEvent(DateOnly date)
{
Navigation.NavigateTo($"/calendar-event/{date:yyyy-MM-dd}");
}
private void OpenWorkUnit(DateOnly date, string workUnitId)
{
Navigation.NavigateTo($"/work-unit/{date:yyyy-MM-dd}/{workUnitId}");
}
private void OpenCalendarEvent(DateOnly date, string eventId)
{
Navigation.NavigateTo($"/calendar-event/{date:yyyy-MM-dd}/{eventId}");
}
private async Task GeneratePreviewWorkUnitsAsync()
{
var confirmed = await JS.InvokeAsync<bool>("confirm", $"Generate preview work units for {firstOfMonth:MMMM yyyy}? Existing work-unit days will be left unchanged.");
if (!confirmed)
{
return;
}
var createdDays = await WorkDayService.GenerateMonthlyPreviewWorkUnitsAsync(firstOfMonth.Year, firstOfMonth.Month);
statusMessage = createdDays == 0
? "No preview work units were created. Every eligible day already had work units or a blocking calendar event."
: $"Created preview work units for {createdDays} day(s).";
await LoadMonth();
}
private string GetCellClass(CalendarCell cell)
{
if (cell.IsWeekend || cell.IsFestivity)
{
return "calendar-weekend";
}
var eventType = GetDominantEventType(cell.Entry);
if (eventType.HasValue)
{
return eventType.Value switch
{
CalendarEventType.Closure => "calendar-closure",
CalendarEventType.Illness => "calendar-illness",
CalendarEventType.DayOff => "calendar-dayoff",
CalendarEventType.Holiday => "calendar-holiday",
_ => string.Empty
};
}
return string.Empty;
}
private static CalendarEventType? GetDominantEventType(WorkDayDocument? day)
{
if (day is null || day.CalendarEvents.Count == 0)
{
return null;
}
if (day.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.Holiday))
{
return CalendarEventType.Holiday;
}
if (day.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.Closure))
{
return CalendarEventType.Closure;
}
if (day.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.DayOff))
{
return CalendarEventType.DayOff;
}
if (day.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.Illness))
{
return CalendarEventType.Illness;
}
return null;
}
private static string GetWorkUnitClass(WorkUnitDocument workUnit)
{
if (workUnit.IsPreview)
{
return workUnit.Location == WorkUnitLocation.Home
? "calendar-item-preview-home"
: "calendar-item-preview-office";
}
return workUnit.Location == WorkUnitLocation.Home ? "calendar-item-home" : "calendar-item-office";
}
private static string GetCalendarEventClass(CalendarEventDocument calendarEvent) => calendarEvent.EventType switch
{
CalendarEventType.Holiday => "calendar-item-holiday",
CalendarEventType.Closure => "calendar-item-closure",
CalendarEventType.DayOff => "calendar-item-dayoff",
CalendarEventType.Illness => "calendar-item-illness",
_ => "calendar-item-generic"
};
private static string FormatWorkUnit(WorkUnitDocument workUnit)
{
var hours = FormatHours(workUnit.ManualWorkedHours);
var timeRange = workUnit.StartTime.HasValue && workUnit.EndTime.HasValue
? $"{workUnit.StartTime:HH:mm}-{workUnit.EndTime:HH:mm}"
: hours;
return workUnit.IsPreview ? $"{timeRange} preview" : timeRange;
}
private static decimal GetDayTotalHours(WorkDayDocument? day, bool includePreview)
{
if (day is null)
{
return 0m;
}
return day.WorkUnits
.Where(unit => includePreview || !unit.IsPreview)
.Sum(unit => unit.ManualWorkedHours);
}
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}";
}
private sealed class CalendarCell
{
public DateOnly Date { get; set; }
public int ColumnIndex { get; set; }
public bool IsWeekend { get; set; }
public bool IsFestivity { get; set; }
public WorkDayDocument? Entry { get; set; }
}
}