Add CalendarEventDocument and CalendarEventType enum for event management Update WorkDayDocument to include WorkUnitDocument and CalendarEventDocument lists Enhance CouchbaseLiteWorkDayService with methods for managing WorkUnit and CalendarEvent Revise MonthlySummaryModel to track preview worked hours and counted work units Improve CSS for calendar view, including responsive design and new item styles
444 lines
18 KiB
Text
444 lines
18 KiB
Text
@page "/calendar"
|
|
@page "/calendar/{YearMonth}"
|
|
@attribute [Authorize]
|
|
@rendermode InteractiveServer
|
|
|
|
@inject IWorkDayService WorkDayService
|
|
@inject IItalianFestivitySource FestivitySource
|
|
@inject NavigationManager Navigation
|
|
@inject IJSRuntime JS
|
|
|
|
<PageTitle>Calendar</PageTitle>
|
|
|
|
<h1>Calendar</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">@firstOfMonth.ToString("MMMM yyyy")</h2>
|
|
<button class="btn btn-outline-secondary btn-sm" @onclick="NextMonth">Next »</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 bg-light"></td>
|
|
}
|
|
else
|
|
{
|
|
<td class="calendar-cell @GetCellClass(cell) @(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" @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>
|
|
}
|
|
}
|
|
|
|
@code {
|
|
[Parameter] public string? YearMonth { get; set; }
|
|
|
|
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();
|
|
}
|
|
|
|
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,
|
|
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 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 bool IsActiveCell(DateOnly date) => activeDate == date;
|
|
|
|
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 bool IsWeekend { get; set; }
|
|
public bool IsFestivity { get; set; }
|
|
public WorkDayDocument? Entry { get; set; }
|
|
}
|
|
}
|