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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue