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>
|
</NavLink>
|
||||||
</div>
|
</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">
|
<div class="nav-item px-3">
|
||||||
<NavLink class="nav-link" href="settings">
|
<NavLink class="nav-link" href="settings">
|
||||||
<span class="bi bi-gear-fill-nav-menu" aria-hidden="true"></span> 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");
|
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 {
|
.nav-item {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
padding-bottom: 0.5rem;
|
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>
|
<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="row g-3 mt-1">
|
||||||
<div class="col-12 col-md-6 col-xl-4">
|
<div class="col-12 col-md-6 col-xl-4">
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="h5 card-title">Default work model</h2>
|
<h2 class="h5 card-title">Today</h2>
|
||||||
<ul class="mb-0">
|
<p class="mb-2">Quick-add or edit today's work entry.</p>
|
||||||
<li>Standard day: 8h</li>
|
<a href="workday" class="btn btn-primary">Open Today</a>
|
||||||
<li>Lunch break: 1h</li>
|
|
||||||
<li>Hourly gross: €17.50</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6 col-xl-4">
|
<div class="col-12 col-md-6 col-xl-4">
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="h5 card-title">Tax coefficients</h2>
|
<h2 class="h5 card-title">Grid View</h2>
|
||||||
<ul class="mb-0">
|
<p class="mb-2">Tabular view of all days in the current month.</p>
|
||||||
<li>Redditività: 67%</li>
|
<a href="grid" class="btn btn-outline-primary">Open Grid</a>
|
||||||
<li>INPS: 26,07%</li>
|
|
||||||
<li>Imposta sostitutiva: 15%</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 h-100">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="h5 card-title">Next step</h2>
|
<h2 class="h5 card-title">Calendar</h2>
|
||||||
<p class="mb-0">Open <a href="settings">Settings</a> to adjust the default values used to prefill each workday.</p>
|
<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>
|
</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
|
||||||
@using WorkTracker.Components
|
@using WorkTracker.Components
|
||||||
@using WorkTracker.Domain
|
@using WorkTracker.Domain
|
||||||
|
@using WorkTracker.Services.Festivities
|
||||||
@using WorkTracker.Services.Settings
|
@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.Festivities;
|
||||||
using WorkTracker.Services.Settings;
|
using WorkTracker.Services.Settings;
|
||||||
using WorkTracker.Services.Storage;
|
using WorkTracker.Services.Storage;
|
||||||
|
using WorkTracker.Services.WorkDays;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
|
@ -46,6 +47,7 @@ builder.Services.AddSingleton<CouchbaseLiteDatabaseProvider>();
|
||||||
builder.Services.AddScoped<IAppSettingsService, CouchbaseLiteAppSettingsService>();
|
builder.Services.AddScoped<IAppSettingsService, CouchbaseLiteAppSettingsService>();
|
||||||
builder.Services.AddSingleton<IAuthService, CouchbaseLiteAuthService>();
|
builder.Services.AddSingleton<IAuthService, CouchbaseLiteAuthService>();
|
||||||
builder.Services.AddSingleton<IItalianFestivitySource, ItalianFestivitySource>();
|
builder.Services.AddSingleton<IItalianFestivitySource, ItalianFestivitySource>();
|
||||||
|
builder.Services.AddScoped<IWorkDayService, CouchbaseLiteWorkDayService>();
|
||||||
builder.Services.AddHostedService<SingleUserSeedService>();
|
builder.Services.AddHostedService<SingleUserSeedService>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ public sealed class CouchbaseLiteDatabaseProvider : IDisposable
|
||||||
{
|
{
|
||||||
private const string AppSettingsCollectionName = "app_settings";
|
private const string AppSettingsCollectionName = "app_settings";
|
||||||
private const string UsersCollectionName = "users";
|
private const string UsersCollectionName = "users";
|
||||||
|
private const string WorkDaysCollectionName = "workdays";
|
||||||
|
|
||||||
private readonly Database database;
|
private readonly Database database;
|
||||||
|
|
||||||
|
|
@ -26,12 +27,15 @@ public sealed class CouchbaseLiteDatabaseProvider : IDisposable
|
||||||
|
|
||||||
AppSettings = database.GetCollection(AppSettingsCollectionName) ?? database.CreateCollection(AppSettingsCollectionName);
|
AppSettings = database.GetCollection(AppSettingsCollectionName) ?? database.CreateCollection(AppSettingsCollectionName);
|
||||||
Users = database.GetCollection(UsersCollectionName) ?? database.CreateCollection(UsersCollectionName);
|
Users = database.GetCollection(UsersCollectionName) ?? database.CreateCollection(UsersCollectionName);
|
||||||
|
WorkDays = database.GetCollection(WorkDaysCollectionName) ?? database.CreateCollection(WorkDaysCollectionName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Collection AppSettings { get; }
|
public Collection AppSettings { get; }
|
||||||
|
|
||||||
public Collection Users { get; }
|
public Collection Users { get; }
|
||||||
|
|
||||||
|
public Collection WorkDays { get; }
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
database.Close();
|
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
|
## 1) Chosen stack
|
||||||
- **Frontend + backend host**: ASP.NET Core + Blazor Web App (.NET 9)
|
- **Frontend + backend host**: ASP.NET Core + Blazor Web App (.NET 9)
|
||||||
- **Database**: Couchbase Lite (local embedded database)
|
- **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:
|
Why this stack:
|
||||||
- Fits CRUD-heavy workflow with strong typing and server-side calculations.
|
- Fits CRUD-heavy workflow with strong typing and server-side calculations.
|
||||||
|
|
|
||||||
|
|
@ -57,4 +57,47 @@ h1:focus {
|
||||||
|
|
||||||
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
|
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
|
||||||
text-align: start;
|
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