feat: Add Grid View and Monthly Summary pages with workday management
Some checks failed
Publish Container / publish (push) Failing after 1m2s
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:
parent
6e3371514e
commit
3ccce7e8a6
17 changed files with 1257 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
190
Components/Pages/CalendarView.razor
Normal file
190
Components/Pages/CalendarView.razor
Normal 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">« Prev</button>
|
||||
<h2 class="h5 mb-0">@firstOfMonth.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">
|
||||
<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; }
|
||||
}
|
||||
}
|
||||
164
Components/Pages/GridView.razor
Normal file
164
Components/Pages/GridView.razor
Normal 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">« 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">
|
||||
<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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
155
Components/Pages/MonthlySummary.razor
Normal file
155
Components/Pages/MonthlySummary.razor
Normal 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">« Prev</button>
|
||||
<h2 class="h5 mb-0">@currentMonth.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 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();
|
||||
}
|
||||
}
|
||||
296
Components/Pages/WorkDayEditor.razor
Normal file
296
Components/Pages/WorkDayEditor.razor
Normal 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}";
|
||||
}
|
||||
}
|
||||
|
|
@ -11,4 +11,6 @@
|
|||
@using WorkTracker
|
||||
@using WorkTracker.Components
|
||||
@using WorkTracker.Domain
|
||||
@using WorkTracker.Services.Festivities
|
||||
@using WorkTracker.Services.Settings
|
||||
@using WorkTracker.Services.WorkDays
|
||||
|
|
|
|||
16
Domain/CoeffSnapshotDocument.cs
Normal file
16
Domain/CoeffSnapshotDocument.cs
Normal 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;
|
||||
}
|
||||
30
Domain/MonthlySummaryModel.cs
Normal file
30
Domain/MonthlySummaryModel.cs
Normal 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
40
Domain/WorkDayDocument.cs
Normal 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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
237
Services/WorkDays/CouchbaseLiteWorkDayService.cs
Normal file
237
Services/WorkDays/CouchbaseLiteWorkDayService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
14
Services/WorkDays/IWorkDayService.cs
Normal file
14
Services/WorkDays/IWorkDayService.cs
Normal 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);
|
||||
}
|
||||
1
plan.md
1
plan.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -58,3 +58,46 @@ 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue