feat: Add Grid View and Monthly Summary pages with workday management
Some checks failed
Publish Container / publish (push) Failing after 1m2s

- Implement GridView.razor for displaying a tabular view of workdays in the current month.
- Create MonthlySummary.razor to show a summary of worked hours, income, and day types for the selected month.
- Introduce WorkDayEditor.razor for adding and editing workday entries with detailed calculations.
- Update Home.razor to include links to the new Grid View and Monthly Summary pages.
- Add IWorkDayService interface and CouchbaseLiteWorkDayService implementation for managing workday data.
- Define domain models: WorkDayDocument, MonthlySummaryModel, and CoeffSnapshotDocument for data structure.
- Enhance CouchbaseLiteDatabaseProvider to include a collection for workdays.
- Update app settings and services to support new features.
- Add CSS styles for calendar view and table formatting.
This commit is contained in:
MaddoScientisto 2026-03-17 22:10:19 +01:00
commit 3ccce7e8a6
17 changed files with 1257 additions and 18 deletions

View file

@ -14,6 +14,30 @@
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="workday">
<span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> New Entry
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="grid">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Grid View
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="calendar">
<span class="bi bi-calendar3-nav-menu" aria-hidden="true"></span> Calendar
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="summary">
<span class="bi bi-bar-chart-fill-nav-menu" aria-hidden="true"></span> Summary
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="settings">
<span class="bi bi-gear-fill-nav-menu" aria-hidden="true"></span> Settings

View file

@ -66,6 +66,18 @@
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-arrow-bar-left' viewBox='0 0 16 16'%3E%3Cpath d='M12.5 15a.5.5 0 0 1-.5-.5v-13a.5.5 0 0 1 1 0v13a.5.5 0 0 1-.5.5ZM10 8a.5.5 0 0 1-.5.5H3.707l2.147 2.146a.5.5 0 0 1-.708.708l-3-3a.5.5 0 0 1 0-.708l3-3a.5.5 0 1 1 .708.708L3.707 7.5H9.5a.5.5 0 0 1 .5.5Z'/%3E%3C/svg%3E");
}
.bi-gear-fill-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='M9.405 1.05c-.413-1.4-2.397-1.4-2.81 0l-.1.34a1.464 1.464 0 0 1-2.105.872l-.31-.17c-1.283-.698-2.686.705-1.987 1.987l.169.311c.446.82.023 1.841-.872 2.105l-.34.1c-1.4.413-1.4 2.397 0 2.81l.34.1a1.464 1.464 0 0 1 .872 2.105l-.17.31c-.698 1.283.705 2.686 1.987 1.987l.311-.169a1.464 1.464 0 0 1 2.105.872l.1.34c.413 1.4 2.397 1.4 2.81 0l.1-.34a1.464 1.464 0 0 1 2.105-.872l.31.17c1.283.698 2.686-.705 1.987-1.987l-.169-.311a1.464 1.464 0 0 1 .872-2.105l.34-.1c1.4-.413 1.4-2.397 0-2.81l-.34-.1a1.464 1.464 0 0 1-.872-2.105l.17-.31c.698-1.283-.705-2.686-1.987-1.987l-.311.169a1.464 1.464 0 0 1-2.105-.872l-.1-.34zM8 10.93a2.929 2.929 0 1 1 0-5.86 2.929 2.929 0 0 1 0 5.858z'/%3E%3C/svg%3E");
}
.bi-calendar3-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='M14 0H2a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zM1 3.857C1 3.384 1.448 3 2 3h12c.552 0 1 .384 1 .857v10.286c0 .473-.448.857-1 .857H2c-.552 0-1-.384-1-.857V3.857z'/%3E%3Cpath d='M6.5 7a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm-9 3a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm-9 3a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm3 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2z'/%3E%3C/svg%3E");
}
.bi-bar-chart-fill-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='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");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;

View file

@ -0,0 +1,190 @@
@page "/calendar"
@page "/calendar/{YearMonth}"
@attribute [Authorize]
@rendermode InteractiveServer
@inject IWorkDayService WorkDayService
@inject IItalianFestivitySource FestivitySource
@inject NavigationManager Navigation
<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">&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>
</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)" @onclick="() => NavigateToDay(cell.Date)" role="button">
<div class="calendar-day-number">@cell.Date.Day</div>
@if (cell.Entry is not null)
{
<span class="badge @GetBadgeClass(cell.Entry.DayType)">@cell.Entry.DayType</span>
<div class="calendar-hours">@cell.Entry.WorkedHoursFinal.ToString("N1")h</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 bg-primary">Work</span>
<span class="badge bg-success">Home</span>
<span class="badge bg-warning text-dark">Closure</span>
<span class="badge bg-info text-dark">Illness</span>
<span class="badge bg-secondary">DayOff</span>
<span class="badge bg-danger">Holiday</span>
</div>
</div>
}
@code {
[Parameter] public string? YearMonth { get; set; }
private DateOnly firstOfMonth;
private bool loading = true;
private List<CalendarCell?[]> weeks = [];
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))
{
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;
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);
// 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 PreviousMonth()
{
firstOfMonth = firstOfMonth.AddMonths(-1);
await LoadMonth();
}
private async Task NextMonth()
{
firstOfMonth = firstOfMonth.AddMonths(1);
await LoadMonth();
}
private void NavigateToDay(DateOnly date) =>
Navigation.NavigateTo($"/workday/{date:yyyy-MM-dd}");
private string GetCellClass(CalendarCell cell)
{
if (cell.IsWeekend || cell.IsFestivity) return "calendar-weekend";
if (cell.Entry is null) return "";
return cell.Entry.DayType switch
{
DayType.Closure => "calendar-closure",
DayType.Illness => "calendar-illness",
DayType.DayOff => "calendar-dayoff",
DayType.Holiday => "calendar-holiday",
_ => ""
};
}
private static string GetBadgeClass(DayType type) => type switch
{
DayType.Work => "bg-primary",
DayType.Home => "bg-success",
DayType.Closure => "bg-warning text-dark",
DayType.Illness => "bg-info text-dark",
DayType.DayOff => "bg-secondary",
DayType.Holiday => "bg-danger",
_ => "bg-light text-dark"
};
private sealed class CalendarCell
{
public DateOnly Date { get; set; }
public bool IsWeekend { get; set; }
public bool IsFestivity { get; set; }
public WorkDayDocument? Entry { get; set; }
}
}

View file

@ -0,0 +1,164 @@
@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">&laquo; Prev</button>
<h2 class="h5 mb-0">@currentDate.ToString("MMMM yyyy")</h2>
<button class="btn btn-outline-secondary btn-sm" @onclick="NextMonth">Next &raquo;</button>
</div>
@if (loading)
{
<p><em>Loading...</em></p>
}
else
{
<div class="table-responsive">
<table class="table table-sm table-bordered align-middle">
<thead class="table-dark">
<tr>
<th>Date</th>
<th>Day</th>
<th>Type</th>
<th>Start</th>
<th>Projected</th>
<th>Actual</th>
<th class="text-end">Worked</th>
<th class="text-end">Extra</th>
<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>
@if (row.Entry is not null)
{
<td>@row.Entry.DayType</td>
<td>@(row.Entry.StartTime?.ToString("HH:mm") ?? "")</td>
<td>@(row.Entry.ProjectedExitTime?.ToString("HH:mm") ?? "")</td>
<td>@(row.Entry.ActualExitTime?.ToString("HH:mm") ?? "")</td>
<td class="text-end">@row.Entry.WorkedHoursFinal.ToString("N2")</td>
<td class="text-end">@FormatDelta(row.Entry.ExtraHoursDelta)</td>
<td class="text-end">@row.Entry.HoursOff.ToString("N2")</td>
<td class="text-end">@row.Entry.GrossIncome.ToString("N2")</td>
<td class="text-end">@row.Entry.NetIncome.ToString("N2")</td>
}
else
{
<td colspan="9" class="text-muted">—</td>
}
<td>
<a href="workday/@row.Date.ToString("yyyy-MM-dd")" class="btn btn-sm btn-outline-primary">Edit</a>
</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)
{
if (row.IsWeekend || row.IsFestivity) return "table-danger";
if (row.Entry is null) return "";
return row.Entry.DayType switch
{
DayType.Closure => "table-warning",
DayType.Illness => "table-info",
DayType.DayOff => "table-secondary",
DayType.Holiday => "table-success",
DayType.Home => "table-light",
_ => ""
};
}
private static string FormatDelta(decimal d) => d switch
{
> 0 => $"+{d:N2}",
< 0 => d.ToString("N2"),
_ => "—"
};
private sealed class CalendarDayRow
{
public DateOnly Date { get; set; }
public bool IsWeekend { get; set; }
public bool IsFestivity { get; set; }
public WorkDayDocument? Entry { get; set; }
}
}

View file

@ -4,38 +4,49 @@
<h1>WorkTracker</h1>
<p class="lead">Phase 1 baseline is active: authentication, locale defaults, and configurable settings with local Couchbase Lite storage.</p>
<div class="row g-3 mt-1">
<div class="col-12 col-md-6 col-xl-4">
<div class="card h-100">
<div class="card-body">
<h2 class="h5 card-title">Default work model</h2>
<ul class="mb-0">
<li>Standard day: 8h</li>
<li>Lunch break: 1h</li>
<li>Hourly gross: €17.50</li>
</ul>
<h2 class="h5 card-title">Today</h2>
<p class="mb-2">Quick-add or edit today's work entry.</p>
<a href="workday" class="btn btn-primary">Open Today</a>
</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">Tax coefficients</h2>
<ul class="mb-0">
<li>Redditività: 67%</li>
<li>INPS: 26,07%</li>
<li>Imposta sostitutiva: 15%</li>
</ul>
<h2 class="h5 card-title">Grid View</h2>
<p class="mb-2">Tabular view of all days in the current month.</p>
<a href="grid" class="btn btn-outline-primary">Open Grid</a>
</div>
</div>
</div>
<div class="col-12 col-md-12 col-xl-4">
<div class="col-12 col-md-6 col-xl-4">
<div class="card h-100">
<div class="card-body">
<h2 class="h5 card-title">Next step</h2>
<p class="mb-0">Open <a href="settings">Settings</a> to adjust the default values used to prefill each workday.</p>
<h2 class="h5 card-title">Calendar</h2>
<p class="mb-2">Visual calendar with day-type badges.</p>
<a href="calendar" class="btn btn-outline-primary">Open Calendar</a>
</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">Monthly Summary</h2>
<p class="mb-2">Totals for worked hours, income, and day types.</p>
<a href="summary" class="btn btn-outline-primary">Open Summary</a>
</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">Settings</h2>
<p class="mb-2">Configure default rates, hours, and tax coefficients.</p>
<a href="settings" class="btn btn-outline-secondary">Open Settings</a>
</div>
</div>
</div>

View file

@ -0,0 +1,155 @@
@page "/summary"
@page "/summary/{YearMonth}"
@attribute [Authorize]
@rendermode InteractiveServer
@inject IWorkDayService WorkDayService
<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">&laquo; Prev</button>
<h2 class="h5 mb-0">@currentMonth.ToString("MMMM yyyy")</h2>
<button class="btn btn-outline-secondary btn-sm" @onclick="NextMonth">Next &raquo;</button>
</div>
@if (loading)
{
<p><em>Loading...</em></p>
}
else if (summary is not null)
{
<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>
<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>
<div class="fs-3 fw-bold">@summary.TotalWorkedHours.ToString("N1")h</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">Hours Off</div>
<div class="fs-3 fw-bold">@summary.TotalHoursOff.ToString("N1")h</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-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">
<div class="text-muted small">Sick Days</div>
<div class="fs-3 fw-bold">@summary.SickDays</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">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">
<div class="text-muted small">Closure Days</div>
<div class="fs-3 fw-bold">@summary.ClosureDays</div>
</div>
</div>
</div>
</div>
}
@code {
[Parameter] public string? YearMonth { get; set; }
private DateOnly currentMonth;
private bool loading = true;
private MonthlySummaryModel? summary;
protected override async Task OnInitializedAsync()
{
if (!string.IsNullOrEmpty(YearMonth) && DateTime.TryParseExact(YearMonth, "yyyy-MM", null, System.Globalization.DateTimeStyles.None, out var parsed))
{
currentMonth = new DateOnly(parsed.Year, parsed.Month, 1);
}
else
{
currentMonth = new DateOnly(DateTime.Today.Year, DateTime.Today.Month, 1);
}
await LoadSummary();
}
private async Task LoadSummary()
{
loading = true;
summary = await WorkDayService.GetMonthlySummaryAsync(currentMonth.Year, currentMonth.Month);
loading = false;
}
private async Task PreviousMonth()
{
currentMonth = currentMonth.AddMonths(-1);
await LoadSummary();
}
private async Task NextMonth()
{
currentMonth = currentMonth.AddMonths(1);
await LoadSummary();
}
}

View file

@ -0,0 +1,296 @@
@page "/workday"
@page "/workday/{DateStr}"
@attribute [Authorize]
@rendermode InteractiveServer
@inject IWorkDayService WorkDayService
@inject IAppSettingsService AppSettingsService
@inject IItalianFestivitySource FestivitySource
@inject NavigationManager Navigation
<PageTitle>Work Day</PageTitle>
<h1>Work Day Entry</h1>
@if (!loaded)
{
<p><em>Loading...</em></p>
}
else
{
<div class="row g-3">
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">Date</label>
<input type="date" class="form-control" value="@selectedDate.ToString("yyyy-MM-dd")" @onchange="OnDateChanged" />
@if (isWeekend || isFestivity)
{
<div class="mt-1">
@if (isWeekend) { <span class="badge bg-danger me-1">Weekend</span> }
@if (isFestivity) { <span class="badge bg-warning text-dark">Festivity</span> }
</div>
}
</div>
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">Day Type</label>
<select class="form-select" value="@selectedDayType" @onchange="OnDayTypeChanged">
@foreach (var dt in Enum.GetValues<DayType>())
{
<option value="@dt">@dt</option>
}
</select>
</div>
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">Start Time</label>
<input type="time" class="form-control" value="@startTimeStr" @onchange="OnStartTimeChanged" />
</div>
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">Actual Exit Time</label>
<input type="time" class="form-control" value="@actualExitTimeStr" @onchange="OnActualExitChanged" />
<div class="form-text">Informational only, not used in calculations.</div>
</div>
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">Extra hours delta</label>
<input type="number" class="form-control" step="0.25" value="@extraHoursDelta" @onchange="OnExtraDeltaChanged" />
</div>
<div class="col-12">
<label class="form-label">Notes</label>
<textarea class="form-control" rows="2" @bind="notes"></textarea>
</div>
</div>
<hr />
<h2 class="h5">Computed values</h2>
<div class="row g-3">
<div class="col-6 col-md-4 col-lg-3">
<label class="form-label text-muted">Projected Exit</label>
<div class="form-control-plaintext fw-bold">@(projectedExitTime?.ToString("HH:mm") ?? "—")</div>
</div>
<div class="col-6 col-md-4 col-lg-3">
<label class="form-label text-muted">Worked (base)</label>
<div class="form-control-plaintext fw-bold">@workedHoursBase.ToString("N2")h</div>
</div>
<div class="col-6 col-md-4 col-lg-3">
<label class="form-label text-muted">Worked (final)</label>
<div class="form-control-plaintext fw-bold">@workedHoursFinal.ToString("N2")h</div>
</div>
<div class="col-6 col-md-4 col-lg-3">
<label class="form-label text-muted">Hours Off</label>
<div class="form-control-plaintext fw-bold">@hoursOff.ToString("N2")h</div>
</div>
<div class="col-6 col-md-4 col-lg-3">
<label class="form-label text-muted">Gross Income</label>
<div class="form-control-plaintext fw-bold">€@grossIncome.ToString("N2")</div>
</div>
<div class="col-6 col-md-4 col-lg-3">
<label class="form-label text-muted">Net Income</label>
<div class="form-control-plaintext fw-bold">€@netIncome.ToString("N2")</div>
</div>
</div>
<div class="d-flex align-items-center gap-2 mt-4">
<button class="btn btn-primary" @onclick="SaveAsync">Save</button>
@if (!string.IsNullOrWhiteSpace(statusMessage))
{
<span class="text-success">@statusMessage</span>
}
</div>
}
@code {
[Parameter] public string? DateStr { get; set; }
private bool loaded;
private DateOnly selectedDate = DateOnly.FromDateTime(DateTime.Today);
private DayType selectedDayType = DayType.None;
private string? startTimeStr;
private string? actualExitTimeStr;
private decimal extraHoursDelta;
private string? notes;
private string? statusMessage;
// Computed preview
private TimeOnly? projectedExitTime;
private decimal workedHoursBase;
private decimal workedHoursFinal;
private decimal hoursOff;
private decimal grossIncome;
private decimal netIncome;
private bool isWeekend;
private bool isFestivity;
// Loaded from settings
private AppSettingsDocument settings = new();
private IReadOnlyCollection<DateOnly> festivities = [];
protected override async Task OnInitializedAsync()
{
if (!string.IsNullOrEmpty(DateStr) && DateOnly.TryParseExact(DateStr, "yyyy-MM-dd", out var parsed))
{
selectedDate = parsed;
}
settings = await AppSettingsService.GetAsync();
festivities = FestivitySource.GetFestivities(selectedDate.Year);
await LoadExistingEntry();
RecomputeFlags();
RecomputePreview();
loaded = true;
}
private async Task LoadExistingEntry()
{
var existing = await WorkDayService.GetAsync(selectedDate);
if (existing is not null)
{
selectedDayType = existing.DayType;
startTimeStr = existing.StartTime?.ToString("HH:mm");
actualExitTimeStr = existing.ActualExitTime?.ToString("HH:mm");
extraHoursDelta = existing.ExtraHoursDelta;
notes = existing.Notes;
}
else
{
selectedDayType = DayType.None;
startTimeStr = null;
actualExitTimeStr = null;
extraHoursDelta = 0;
notes = null;
}
}
private async Task OnDateChanged(ChangeEventArgs e)
{
if (DateOnly.TryParse(e.Value?.ToString(), out var d))
{
selectedDate = d;
festivities = FestivitySource.GetFestivities(selectedDate.Year);
await LoadExistingEntry();
RecomputeFlags();
RecomputePreview();
statusMessage = null;
}
}
private void OnDayTypeChanged(ChangeEventArgs e)
{
if (Enum.TryParse<DayType>(e.Value?.ToString(), out var dt))
{
selectedDayType = dt;
RecomputePreview();
statusMessage = null;
}
}
private void OnStartTimeChanged(ChangeEventArgs e)
{
startTimeStr = e.Value?.ToString();
RecomputePreview();
statusMessage = null;
}
private void OnActualExitChanged(ChangeEventArgs e)
{
actualExitTimeStr = e.Value?.ToString();
statusMessage = null;
}
private void OnExtraDeltaChanged(ChangeEventArgs e)
{
if (decimal.TryParse(e.Value?.ToString(), out var val))
{
extraHoursDelta = val;
}
RecomputePreview();
statusMessage = null;
}
private void RecomputeFlags()
{
isWeekend = selectedDate.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
isFestivity = festivities.Contains(selectedDate);
}
private void RecomputePreview()
{
TimeOnly? start = null;
if (!string.IsNullOrEmpty(startTimeStr) && TimeOnly.TryParse(startTimeStr, out var s))
{
start = s;
}
if (selectedDayType is DayType.Work or DayType.Home)
{
workedHoursBase = settings.StandardWorkHoursPerDay;
if (start.HasValue)
{
var totalHours = settings.StandardWorkHoursPerDay + settings.LunchBreakHours;
projectedExitTime = start.Value.Add(TimeSpan.FromHours((double)totalHours));
}
else
{
projectedExitTime = null;
}
}
else
{
workedHoursBase = 0;
projectedExitTime = null;
}
workedHoursFinal = workedHoursBase + extraHoursDelta;
hoursOff = selectedDayType is DayType.Work or DayType.Home
? Math.Max(0, settings.StandardWorkHoursPerDay - workedHoursFinal)
: 0;
grossIncome = workedHoursFinal * settings.HourlyGrossRate;
var taxableBase = grossIncome * settings.ProfitabilityCoefficient;
netIncome = grossIncome - (taxableBase * settings.InpsRate) - (taxableBase * settings.SubstituteTaxRate);
}
private async Task SaveAsync()
{
TimeOnly? start = null;
TimeOnly? exit = null;
if (!string.IsNullOrEmpty(startTimeStr) && TimeOnly.TryParse(startTimeStr, out var s))
{
start = s;
}
if (!string.IsNullOrEmpty(actualExitTimeStr) && TimeOnly.TryParse(actualExitTimeStr, out var e2))
{
exit = e2;
}
var workDay = new WorkDayDocument
{
Date = selectedDate,
DayType = selectedDayType,
StartTime = start,
ActualExitTime = exit,
ExtraHoursDelta = extraHoursDelta,
Notes = notes
};
var saved = await WorkDayService.SaveAsync(workDay);
// Update preview with saved computed values
projectedExitTime = saved.ProjectedExitTime;
workedHoursBase = saved.WorkedHoursBase;
workedHoursFinal = saved.WorkedHoursFinal;
hoursOff = saved.HoursOff;
grossIncome = saved.GrossIncome;
netIncome = saved.NetIncome;
isWeekend = saved.IsWeekend;
isFestivity = saved.IsItalianFestivity;
statusMessage = $"Saved at {DateTime.Now:t}";
}
}

View file

@ -11,4 +11,6 @@
@using WorkTracker
@using WorkTracker.Components
@using WorkTracker.Domain
@using WorkTracker.Services.Festivities
@using WorkTracker.Services.Settings
@using WorkTracker.Services.WorkDays

View file

@ -0,0 +1,16 @@
namespace WorkTracker.Domain;
public sealed class CoeffSnapshotDocument
{
public decimal StandardWorkHoursPerDay { get; set; } = 8m;
public decimal LunchBreakHours { get; set; } = 1m;
public decimal HourlyGrossRate { get; set; } = 17.5m;
public decimal ProfitabilityCoefficient { get; set; } = 0.67m;
public decimal InpsRate { get; set; } = 0.2607m;
public decimal SubstituteTaxRate { get; set; } = 0.15m;
}

View file

@ -0,0 +1,30 @@
namespace WorkTracker.Domain;
public sealed class MonthlySummaryModel
{
public int Year { get; set; }
public int Month { get; set; }
public decimal TotalWorkedHours { get; set; }
public int OfficeDays { get; set; }
public int HomeDays { get; set; }
public int HolidayDays { get; set; }
public int SickDays { get; set; }
public int DaysOff { get; set; }
public int ClosureDays { get; set; }
public decimal TotalHoursOff { get; set; }
public decimal TotalGrossIncome { get; set; }
public decimal TotalNetIncome { get; set; }
public int TotalWorkingDays { get; set; }
}

40
Domain/WorkDayDocument.cs Normal file
View file

@ -0,0 +1,40 @@
namespace WorkTracker.Domain;
public sealed class WorkDayDocument
{
public string Id { get; set; } = string.Empty;
public DateOnly Date { get; set; }
public TimeOnly? StartTime { get; set; }
public TimeOnly? ProjectedExitTime { get; set; }
public TimeOnly? ActualExitTime { get; set; }
public DayType DayType { get; set; } = DayType.None;
public decimal ExtraHoursDelta { get; set; }
public decimal WorkedHoursBase { get; set; }
public decimal WorkedHoursFinal { get; set; }
public decimal HoursOff { get; set; }
public decimal GrossIncome { get; set; }
public decimal NetIncome { get; set; }
public bool IsWeekend { get; set; }
public bool IsItalianFestivity { get; set; }
public string? Notes { get; set; }
public CoeffSnapshotDocument CoeffSnapshot { get; set; } = new();
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
}

View file

@ -14,6 +14,7 @@ using WorkTracker.Services.Auth;
using WorkTracker.Services.Festivities;
using WorkTracker.Services.Settings;
using WorkTracker.Services.Storage;
using WorkTracker.Services.WorkDays;
var builder = WebApplication.CreateBuilder(args);
@ -46,6 +47,7 @@ builder.Services.AddSingleton<CouchbaseLiteDatabaseProvider>();
builder.Services.AddScoped<IAppSettingsService, CouchbaseLiteAppSettingsService>();
builder.Services.AddSingleton<IAuthService, CouchbaseLiteAuthService>();
builder.Services.AddSingleton<IItalianFestivitySource, ItalianFestivitySource>();
builder.Services.AddScoped<IWorkDayService, CouchbaseLiteWorkDayService>();
builder.Services.AddHostedService<SingleUserSeedService>();
var app = builder.Build();

View file

@ -8,6 +8,7 @@ public sealed class CouchbaseLiteDatabaseProvider : IDisposable
{
private const string AppSettingsCollectionName = "app_settings";
private const string UsersCollectionName = "users";
private const string WorkDaysCollectionName = "workdays";
private readonly Database database;
@ -26,12 +27,15 @@ public sealed class CouchbaseLiteDatabaseProvider : IDisposable
AppSettings = database.GetCollection(AppSettingsCollectionName) ?? database.CreateCollection(AppSettingsCollectionName);
Users = database.GetCollection(UsersCollectionName) ?? database.CreateCollection(UsersCollectionName);
WorkDays = database.GetCollection(WorkDaysCollectionName) ?? database.CreateCollection(WorkDaysCollectionName);
}
public Collection AppSettings { get; }
public Collection Users { get; }
public Collection WorkDays { get; }
public void Dispose()
{
database.Close();

View file

@ -0,0 +1,237 @@
using Couchbase.Lite;
using WorkTracker.Domain;
using WorkTracker.Services.Festivities;
using WorkTracker.Services.Settings;
using WorkTracker.Services.Storage;
namespace WorkTracker.Services.WorkDays;
public sealed class CouchbaseLiteWorkDayService : IWorkDayService
{
private readonly Collection workDaysCollection;
private readonly IAppSettingsService appSettingsService;
private readonly IItalianFestivitySource festivitySource;
public CouchbaseLiteWorkDayService(
CouchbaseLiteDatabaseProvider databaseProvider,
IAppSettingsService appSettingsService,
IItalianFestivitySource festivitySource)
{
workDaysCollection = databaseProvider.WorkDays;
this.appSettingsService = appSettingsService;
this.festivitySource = festivitySource;
}
public Task<WorkDayDocument?> GetAsync(DateOnly date, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var id = date.ToString("yyyy-MM-dd");
var doc = workDaysCollection.GetDocument(id);
return Task.FromResult(doc is not null ? Map(doc) : null);
}
public async Task<WorkDayDocument> SaveAsync(WorkDayDocument workDay, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var settings = await appSettingsService.GetAsync(cancellationToken);
var festivities = festivitySource.GetFestivities(workDay.Date.Year);
workDay.Id = workDay.Date.ToString("yyyy-MM-dd");
workDay.IsWeekend = workDay.Date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
workDay.IsItalianFestivity = festivities.Contains(workDay.Date);
// Snapshot coefficients from current settings
workDay.CoeffSnapshot = new CoeffSnapshotDocument
{
StandardWorkHoursPerDay = settings.StandardWorkHoursPerDay,
LunchBreakHours = settings.LunchBreakHours,
HourlyGrossRate = settings.HourlyGrossRate,
ProfitabilityCoefficient = settings.ProfitabilityCoefficient,
InpsRate = settings.InpsRate,
SubstituteTaxRate = settings.SubstituteTaxRate
};
Compute(workDay);
// Preserve creation timestamp for existing documents
var existing = workDaysCollection.GetDocument(workDay.Id);
if (existing is not null)
{
workDay.CreatedAtUtc = ReadDateTimeOffset(existing, "createdAtUtc");
}
else
{
workDay.CreatedAtUtc = DateTimeOffset.UtcNow;
}
workDay.UpdatedAtUtc = DateTimeOffset.UtcNow;
SaveDocument(workDay);
return workDay;
}
public Task<IReadOnlyList<WorkDayDocument>> GetRangeAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var results = new List<WorkDayDocument>();
for (var date = from; date <= to; date = date.AddDays(1))
{
var id = date.ToString("yyyy-MM-dd");
var doc = workDaysCollection.GetDocument(id);
if (doc is not null)
{
results.Add(Map(doc));
}
}
return Task.FromResult<IReadOnlyList<WorkDayDocument>>(results);
}
public async Task<MonthlySummaryModel> GetMonthlySummaryAsync(int year, int month, CancellationToken cancellationToken = default)
{
var from = new DateOnly(year, month, 1);
var to = from.AddMonths(1).AddDays(-1);
var days = await GetRangeAsync(from, to, cancellationToken);
return new MonthlySummaryModel
{
Year = year,
Month = month,
TotalWorkedHours = days.Sum(d => d.WorkedHoursFinal),
OfficeDays = days.Count(d => d.DayType == DayType.Work),
HomeDays = days.Count(d => d.DayType == DayType.Home),
HolidayDays = days.Count(d => d.DayType == DayType.Holiday),
SickDays = days.Count(d => d.DayType == DayType.Illness),
DaysOff = days.Count(d => d.DayType == DayType.DayOff),
ClosureDays = days.Count(d => d.DayType == DayType.Closure),
TotalHoursOff = days.Sum(d => d.HoursOff),
TotalGrossIncome = days.Sum(d => d.GrossIncome),
TotalNetIncome = days.Sum(d => d.NetIncome),
TotalWorkingDays = days.Count(d => d.DayType is DayType.Work or DayType.Home)
};
}
private static void Compute(WorkDayDocument day)
{
var coeff = day.CoeffSnapshot;
// Calculate projected exit time
if (day.StartTime.HasValue && day.DayType is DayType.Work or DayType.Home)
{
var totalHours = coeff.StandardWorkHoursPerDay + coeff.LunchBreakHours;
day.ProjectedExitTime = day.StartTime.Value.Add(TimeSpan.FromHours((double)totalHours));
}
else
{
day.ProjectedExitTime = null;
}
// Calculate worked hours
day.WorkedHoursBase = day.DayType is DayType.Work or DayType.Home
? coeff.StandardWorkHoursPerDay
: 0m;
day.WorkedHoursFinal = day.WorkedHoursBase + day.ExtraHoursDelta;
// Hours off (only for work/home days)
day.HoursOff = day.DayType is DayType.Work or DayType.Home
? Math.Max(0m, coeff.StandardWorkHoursPerDay - day.WorkedHoursFinal)
: 0m;
// Income calculations
day.GrossIncome = day.WorkedHoursFinal * coeff.HourlyGrossRate;
var taxableBase = day.GrossIncome * coeff.ProfitabilityCoefficient;
day.NetIncome = day.GrossIncome - (taxableBase * coeff.InpsRate) - (taxableBase * coeff.SubstituteTaxRate);
}
private void SaveDocument(WorkDayDocument day)
{
var doc = new MutableDocument(day.Id);
doc.SetString("date", day.Date.ToString("yyyy-MM-dd"));
doc.SetString("startTime", day.StartTime?.ToString("HH:mm"));
doc.SetString("projectedExitTime", day.ProjectedExitTime?.ToString("HH:mm"));
doc.SetString("actualExitTime", day.ActualExitTime?.ToString("HH:mm"));
doc.SetInt("dayType", (int)day.DayType);
doc.SetDouble("extraHoursDelta", decimal.ToDouble(day.ExtraHoursDelta));
doc.SetDouble("workedHoursBase", decimal.ToDouble(day.WorkedHoursBase));
doc.SetDouble("workedHoursFinal", decimal.ToDouble(day.WorkedHoursFinal));
doc.SetDouble("hoursOff", decimal.ToDouble(day.HoursOff));
doc.SetDouble("grossIncome", decimal.ToDouble(day.GrossIncome));
doc.SetDouble("netIncome", decimal.ToDouble(day.NetIncome));
doc.SetBoolean("isWeekend", day.IsWeekend);
doc.SetBoolean("isItalianFestivity", day.IsItalianFestivity);
doc.SetString("notes", day.Notes);
// Coefficient snapshot
doc.SetDouble("coeff_standardWorkHoursPerDay", decimal.ToDouble(day.CoeffSnapshot.StandardWorkHoursPerDay));
doc.SetDouble("coeff_lunchBreakHours", decimal.ToDouble(day.CoeffSnapshot.LunchBreakHours));
doc.SetDouble("coeff_hourlyGrossRate", decimal.ToDouble(day.CoeffSnapshot.HourlyGrossRate));
doc.SetDouble("coeff_profitabilityCoefficient", decimal.ToDouble(day.CoeffSnapshot.ProfitabilityCoefficient));
doc.SetDouble("coeff_inpsRate", decimal.ToDouble(day.CoeffSnapshot.InpsRate));
doc.SetDouble("coeff_substituteTaxRate", decimal.ToDouble(day.CoeffSnapshot.SubstituteTaxRate));
doc.SetString("createdAtUtc", day.CreatedAtUtc.ToString("O"));
doc.SetString("updatedAtUtc", day.UpdatedAtUtc.ToString("O"));
workDaysCollection.Save(doc);
}
private static WorkDayDocument Map(Document doc)
{
return new WorkDayDocument
{
Id = doc.Id,
Date = DateOnly.ParseExact(doc.GetString("date") ?? doc.Id, "yyyy-MM-dd"),
StartTime = ReadTimeOnly(doc, "startTime"),
ProjectedExitTime = ReadTimeOnly(doc, "projectedExitTime"),
ActualExitTime = ReadTimeOnly(doc, "actualExitTime"),
DayType = (DayType)doc.GetInt("dayType"),
ExtraHoursDelta = Convert.ToDecimal(doc.GetDouble("extraHoursDelta")),
WorkedHoursBase = Convert.ToDecimal(doc.GetDouble("workedHoursBase")),
WorkedHoursFinal = Convert.ToDecimal(doc.GetDouble("workedHoursFinal")),
HoursOff = Convert.ToDecimal(doc.GetDouble("hoursOff")),
GrossIncome = Convert.ToDecimal(doc.GetDouble("grossIncome")),
NetIncome = Convert.ToDecimal(doc.GetDouble("netIncome")),
IsWeekend = doc.GetBoolean("isWeekend"),
IsItalianFestivity = doc.GetBoolean("isItalianFestivity"),
Notes = doc.GetString("notes"),
CoeffSnapshot = new CoeffSnapshotDocument
{
StandardWorkHoursPerDay = ReadDecimal(doc, "coeff_standardWorkHoursPerDay", 8m),
LunchBreakHours = ReadDecimal(doc, "coeff_lunchBreakHours", 1m),
HourlyGrossRate = ReadDecimal(doc, "coeff_hourlyGrossRate", 17.5m),
ProfitabilityCoefficient = ReadDecimal(doc, "coeff_profitabilityCoefficient", 0.67m),
InpsRate = ReadDecimal(doc, "coeff_inpsRate", 0.2607m),
SubstituteTaxRate = ReadDecimal(doc, "coeff_substituteTaxRate", 0.15m)
},
CreatedAtUtc = ReadDateTimeOffset(doc, "createdAtUtc"),
UpdatedAtUtc = ReadDateTimeOffset(doc, "updatedAtUtc")
};
}
private static TimeOnly? ReadTimeOnly(Document doc, string key)
{
var value = doc.GetString(key);
return !string.IsNullOrEmpty(value) && TimeOnly.TryParseExact(value, "HH:mm", out var time)
? time
: null;
}
private static decimal ReadDecimal(Document doc, string key, decimal defaultValue)
{
return doc.Contains(key)
? Convert.ToDecimal(doc.GetDouble(key))
: defaultValue;
}
private static DateTimeOffset ReadDateTimeOffset(Document doc, string key)
{
var value = doc.GetString(key);
return !string.IsNullOrEmpty(value) && DateTimeOffset.TryParse(value, out var dt)
? dt
: DateTimeOffset.UtcNow;
}
}

View file

@ -0,0 +1,14 @@
using WorkTracker.Domain;
namespace WorkTracker.Services.WorkDays;
public interface IWorkDayService
{
Task<WorkDayDocument?> GetAsync(DateOnly date, CancellationToken cancellationToken = default);
Task<WorkDayDocument> SaveAsync(WorkDayDocument workDay, CancellationToken cancellationToken = default);
Task<IReadOnlyList<WorkDayDocument>> GetRangeAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default);
Task<MonthlySummaryModel> GetMonthlySummaryAsync(int year, int month, CancellationToken cancellationToken = default);
}

View file

@ -3,7 +3,6 @@
## 1) Chosen stack
- **Frontend + backend host**: ASP.NET Core + Blazor Web App (.NET 9)
- **Database**: Couchbase Lite (local embedded database)
- **Auth approach (single user)**: ASP.NET Core Identity configured and enabled now; registration can be disabled later and one seeded account can be used.
Why this stack:
- Fits CRUD-heavy workflow with strong typing and server-side calculations.

View file

@ -57,4 +57,47 @@ h1:focus {
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
text-align: start;
}
/* Calendar view */
.calendar-table td.calendar-cell {
height: 5rem;
vertical-align: top;
padding: 0.25rem 0.4rem;
cursor: pointer;
min-width: 5rem;
}
.calendar-table td.calendar-cell:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.calendar-day-number {
font-weight: bold;
font-size: 0.9rem;
}
.calendar-hours {
font-size: 0.75rem;
color: #666;
}
.calendar-weekend {
background-color: #ffe0e0 !important;
}
.calendar-closure {
background-color: #fff3cd !important;
}
.calendar-illness {
background-color: #d1ecf1 !important;
}
.calendar-dayoff {
background-color: #e2e3e5 !important;
}
.calendar-holiday {
background-color: #d4edda !important;
}