Compare commits
3 commits
08e573d63c
...
c8c5f0071f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8c5f0071f | ||
|
|
a7f8dfba01 | ||
|
|
cab549ab3a |
30 changed files with 2469 additions and 416 deletions
50
.vscode/launch.json
vendored
50
.vscode/launch.json
vendored
|
|
@ -1,16 +1,50 @@
|
|||
{
|
||||
"version": "0.2.0",
|
||||
"compounds": [
|
||||
"compounds": [],
|
||||
"configurations": [
|
||||
{
|
||||
"name": "WorkTracker: Debug in Docker",
|
||||
"configurations": [
|
||||
"WorkTracker: Debug App in Docker",
|
||||
"WorkTracker: Debug Edge"
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "WorkTracker: Docker Debug Prepare",
|
||||
"postDebugTask": "WorkTracker: Docker Debug Down",
|
||||
"program": "/workspace/bin/Debug/net10.0/WorkTracker.dll",
|
||||
"args": [
|
||||
"--urls",
|
||||
"http://+:8080"
|
||||
],
|
||||
"stopAll": true
|
||||
}
|
||||
],
|
||||
"configurations": [
|
||||
"cwd": "/workspace",
|
||||
"env": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"ASPNETCORE_URLS": "http://+:8080",
|
||||
"DOTNET_USE_POLLING_FILE_WATCHER": "1",
|
||||
"UseHttpsRedirection": "false"
|
||||
},
|
||||
"sourceFileMap": {
|
||||
"/workspace": "${workspaceFolder}"
|
||||
},
|
||||
"pipeTransport": {
|
||||
"pipeProgram": "docker",
|
||||
"pipeArgs": [
|
||||
"exec",
|
||||
"-i",
|
||||
"worktracker-dev",
|
||||
"sh",
|
||||
"-c"
|
||||
],
|
||||
"debuggerPath": "/vsdbg/vsdbg",
|
||||
"pipeCwd": "${workspaceFolder}",
|
||||
"quoteArgs": false
|
||||
},
|
||||
"serverReadyAction": {
|
||||
"action": "debugWithEdge",
|
||||
"pattern": "Now listening on:\\s+https?://\\S+:(\\d+)",
|
||||
"uriFormat": "http://localhost:8002/?ready=%s"
|
||||
},
|
||||
"justMyCode": true,
|
||||
"requireExactSource": false,
|
||||
"console": "internalConsole"
|
||||
},
|
||||
{
|
||||
"name": "WorkTracker: Debug App in Docker",
|
||||
"type": "coreclr",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
</head>
|
||||
|
||||
<body>
|
||||
<Routes />
|
||||
<Routes @rendermode="InteractiveServer" />
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,24 @@
|
|||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="page">
|
||||
<div class="sidebar">
|
||||
<NavMenu />
|
||||
<div class="page @(sidebarCollapsed ? "sidebar-collapsed" : string.Empty)">
|
||||
<div class="sidebar @(sidebarCollapsed ? "sidebar-collapsed" : string.Empty)">
|
||||
<NavMenu IsCollapsed="sidebarCollapsed" />
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div class="top-row px-4">
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar-toggle"
|
||||
@onclick="ToggleSidebar"
|
||||
aria-label="Toggle sidebar"
|
||||
aria-controls="sidebar-navigation"
|
||||
aria-expanded="@(sidebarCollapsed ? "false" : "true")"
|
||||
title="Toggle sidebar">
|
||||
<span class="sidebar-toggle-bar"></span>
|
||||
<span class="sidebar-toggle-bar"></span>
|
||||
<span class="sidebar-toggle-bar"></span>
|
||||
</button>
|
||||
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
|
||||
</div>
|
||||
|
||||
|
|
@ -21,3 +33,12 @@
|
|||
<a href="." class="reload">Reload</a>
|
||||
<span class="dismiss">🗙</span>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private bool sidebarCollapsed = true;
|
||||
|
||||
private void ToggleSidebar()
|
||||
{
|
||||
sidebarCollapsed = !sidebarCollapsed;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
|
|
@ -36,6 +37,27 @@ main {
|
|||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
margin-right: auto;
|
||||
border: 1px solid #d6d5d5;
|
||||
border-radius: 0.65rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.sidebar-toggle-bar {
|
||||
width: 1rem;
|
||||
height: 2px;
|
||||
background: #334155;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@media (max-width: 640.98px) {
|
||||
.top-row {
|
||||
justify-content: space-between;
|
||||
|
|
@ -56,6 +78,9 @@ main {
|
|||
height: 100vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
transition: width 0.2s ease;
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.top-row {
|
||||
|
|
@ -74,6 +99,14 @@ main {
|
|||
padding-left: 2rem !important;
|
||||
padding-right: 1.5rem !important;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.sidebar.sidebar-collapsed {
|
||||
width: 5rem;
|
||||
}
|
||||
}
|
||||
|
||||
#blazor-error-ui {
|
||||
|
|
|
|||
|
|
@ -2,65 +2,72 @@
|
|||
@using WorkTracker.Configuration
|
||||
@inject IOptions<AppAuthOptions> AppAuthOptions
|
||||
|
||||
@code {
|
||||
[Parameter] public bool IsCollapsed { get; set; }
|
||||
}
|
||||
|
||||
<div class="nav-menu-shell @(IsCollapsed ? "nav-menu-shell-collapsed" : string.Empty)" data-testid="sidebar-shell" data-collapsed="@(IsCollapsed ? "true" : "false")">
|
||||
<div class="top-row ps-3 navbar navbar-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="">WorkTracker</a>
|
||||
<a class="navbar-brand" href="" aria-label="WorkTracker home">
|
||||
<span class="sidebar-brand-full">WorkTracker</span>
|
||||
<span class="sidebar-brand-compact" aria-hidden="true">WT</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
|
||||
|
||||
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
|
||||
<nav class="nav flex-column">
|
||||
<div class="nav-scrollable">
|
||||
<nav id="sidebar-navigation" class="nav flex-column" aria-label="Sidebar navigation">
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
||||
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Dashboard
|
||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All" aria-label="Dashboard" title="Dashboard">
|
||||
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span>
|
||||
<span class="nav-label">Dashboard</span>
|
||||
</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 class="nav-link" href="grid" aria-label="Grid View" title="Grid View">
|
||||
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span>
|
||||
<span class="nav-label">Grid View</span>
|
||||
</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 class="nav-link" href="calendar" aria-label="Calendar" title="Calendar">
|
||||
<span class="bi bi-calendar3-nav-menu" aria-hidden="true"></span>
|
||||
<span class="nav-label">Calendar</span>
|
||||
</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 class="nav-link" href="summary" aria-label="Summary" title="Summary">
|
||||
<span class="bi bi-bar-chart-fill-nav-menu" aria-hidden="true"></span>
|
||||
<span class="nav-label">Summary</span>
|
||||
</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
|
||||
<NavLink class="nav-link" href="settings" aria-label="Settings" title="Settings">
|
||||
<span class="bi bi-gear-fill-nav-menu" aria-hidden="true"></span>
|
||||
<span class="nav-label">Settings</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="auth">
|
||||
<span class="bi bi-person-fill-nav-menu" aria-hidden="true"></span> @context.User.Identity?.Name
|
||||
<NavLink class="nav-link" href="auth" aria-label="@context.User.Identity?.Name" title="@context.User.Identity?.Name">
|
||||
<span class="bi bi-person-fill-nav-menu" aria-hidden="true"></span>
|
||||
<span class="nav-label">@context.User.Identity?.Name</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
@if (AppAuthOptions.Value.Enabled)
|
||||
{
|
||||
<div class="nav-item px-3">
|
||||
<form action="/api/logout" method="post">
|
||||
<button type="submit" class="nav-link">
|
||||
<span class="bi bi-arrow-bar-left-nav-menu" aria-hidden="true"></span> Logout
|
||||
<button type="submit" class="nav-link" aria-label="Logout" title="Logout">
|
||||
<span class="bi bi-arrow-bar-left-nav-menu" aria-hidden="true"></span>
|
||||
<span class="nav-label">Logout</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -68,13 +75,15 @@
|
|||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="login">
|
||||
<span class="bi bi-person-badge-nav-menu" aria-hidden="true"></span> Login
|
||||
<NavLink class="nav-link" href="login" aria-label="Login" title="Login">
|
||||
<span class="bi bi-person-badge-nav-menu" aria-hidden="true"></span>
|
||||
<span class="nav-label">Login</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +1,3 @@
|
|||
.navbar-toggler {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
width: 3.5rem;
|
||||
height: 2.5rem;
|
||||
color: white;
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.navbar-toggler:checked {
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
min-height: 3.5rem;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
|
|
@ -24,6 +7,14 @@
|
|||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.sidebar-brand-compact {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-menu-shell {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bi {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
|
|
@ -83,6 +74,12 @@
|
|||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-item:first-of-type {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
|
@ -114,24 +111,59 @@
|
|||
}
|
||||
|
||||
.nav-scrollable {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-toggler:checked ~ .nav-scrollable {
|
||||
display: block;
|
||||
height: calc(100vh - 3.5rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.navbar-toggler {
|
||||
@media (max-width: 640.98px) {
|
||||
.nav-menu-shell-collapsed .sidebar-brand-full,
|
||||
.nav-menu-shell:not(.nav-menu-shell-collapsed) .sidebar-brand-compact {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
/* Never collapse the sidebar for wide screens */
|
||||
display: block;
|
||||
|
||||
/* Allow sidebar to scroll for tall menus */
|
||||
height: calc(100vh - 3.5rem);
|
||||
overflow-y: auto;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.nav-menu-shell-collapsed .top-row {
|
||||
padding-left: 0.5rem !important;
|
||||
padding-right: 0.5rem !important;
|
||||
}
|
||||
|
||||
.nav-menu-shell-collapsed .container-fluid {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-menu-shell-collapsed .sidebar-brand-full,
|
||||
.nav-menu-shell:not(.nav-menu-shell-collapsed) .sidebar-brand-compact,
|
||||
.nav-menu-shell-collapsed .nav-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-menu-shell-collapsed .nav-item {
|
||||
padding-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.nav-menu-shell-collapsed .nav-item.px-3 {
|
||||
padding-left: 0.5rem !important;
|
||||
padding-right: 0.5rem !important;
|
||||
}
|
||||
|
||||
.nav-menu-shell-collapsed .nav-item ::deep .nav-link {
|
||||
justify-content: center;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.nav-menu-shell-collapsed .nav-item ::deep .nav-link,
|
||||
.nav-menu-shell-collapsed .nav-item ::deep button.nav-link {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav-menu-shell-collapsed .bi {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
232
Components/Pages/CalendarEventEditor.razor
Normal file
232
Components/Pages/CalendarEventEditor.razor
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
@page "/calendar-event"
|
||||
@page "/calendar-event/{DateStr}"
|
||||
@page "/calendar-event/{DateStr}/{EventId}"
|
||||
@attribute [Authorize]
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@inject IWorkDayService WorkDayService
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<PageTitle>Calendar Event</PageTitle>
|
||||
|
||||
<h1>Calendar Event</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" disabled="@isExistingEvent" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<label class="form-label">Entry Type</label>
|
||||
<select class="form-select" @bind="eventType">
|
||||
@foreach (var item in Enum.GetValues<CalendarEventType>())
|
||||
{
|
||||
<option value="@item">@item</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-8">
|
||||
<label class="form-label">Description</label>
|
||||
<input class="form-control" @bind="description" maxlength="120" />
|
||||
</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">End Time</label>
|
||||
<input type="time" class="form-control" value="@endTimeStr" @onchange="OnEndTimeChanged" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<label class="form-label text-muted">Duration</label>
|
||||
<div class="form-control-plaintext fw-bold">@FormatDuration()</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center gap-2 mt-4">
|
||||
<button class="btn btn-primary" @onclick="SaveAsync">Save</button>
|
||||
@if (isExistingEvent)
|
||||
{
|
||||
<button class="btn btn-outline-danger" @onclick="DeleteAsync">Delete</button>
|
||||
}
|
||||
<button class="btn btn-outline-secondary" @onclick="BackToCalendar">Back to Calendar</button>
|
||||
@if (!string.IsNullOrWhiteSpace(statusMessage))
|
||||
{
|
||||
<span class="text-success">@statusMessage</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string? DateStr { get; set; }
|
||||
[Parameter] public string? EventId { get; set; }
|
||||
|
||||
private bool loaded;
|
||||
private bool isExistingEvent;
|
||||
private DateOnly selectedDate = DateOnly.FromDateTime(DateTime.Today);
|
||||
private string eventId = string.Empty;
|
||||
private CalendarEventType eventType = CalendarEventType.Generic;
|
||||
private string description = "Calendar entry";
|
||||
private string? startTimeStr;
|
||||
private string? endTimeStr;
|
||||
private string? statusMessage;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(DateStr) && DateOnly.TryParseExact(DateStr, "yyyy-MM-dd", out var parsed))
|
||||
{
|
||||
selectedDate = parsed;
|
||||
}
|
||||
|
||||
await LoadEventAsync();
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
private async Task LoadEventAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(EventId))
|
||||
{
|
||||
SetDefaults();
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = await WorkDayService.GetCalendarEventAsync(selectedDate, EventId);
|
||||
if (existing is not null)
|
||||
{
|
||||
eventId = existing.Id;
|
||||
eventType = existing.EventType;
|
||||
description = existing.Description;
|
||||
startTimeStr = existing.StartTime?.ToString("HH:mm");
|
||||
endTimeStr = existing.EndTime?.ToString("HH:mm");
|
||||
isExistingEvent = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
SetDefaults();
|
||||
statusMessage = "The selected calendar event was not found. A new event will be created for this day.";
|
||||
}
|
||||
}
|
||||
|
||||
private void SetDefaults()
|
||||
{
|
||||
eventId = string.Empty;
|
||||
eventType = CalendarEventType.Generic;
|
||||
description = "Calendar entry";
|
||||
startTimeStr = null;
|
||||
endTimeStr = null;
|
||||
isExistingEvent = false;
|
||||
}
|
||||
|
||||
private Task OnDateChanged(ChangeEventArgs e)
|
||||
{
|
||||
if (DateOnly.TryParse(e.Value?.ToString(), out var parsed))
|
||||
{
|
||||
selectedDate = parsed;
|
||||
statusMessage = null;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task OnStartTimeChanged(ChangeEventArgs e)
|
||||
{
|
||||
startTimeStr = e.Value?.ToString();
|
||||
statusMessage = null;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task OnEndTimeChanged(ChangeEventArgs e)
|
||||
{
|
||||
endTimeStr = e.Value?.ToString();
|
||||
statusMessage = null;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
var calendarEvent = new CalendarEventDocument
|
||||
{
|
||||
Id = eventId,
|
||||
EventType = eventType,
|
||||
Description = description,
|
||||
StartTime = ParseTime(startTimeStr),
|
||||
EndTime = ParseTime(endTimeStr)
|
||||
};
|
||||
|
||||
var saved = await WorkDayService.SaveCalendarEventAsync(selectedDate, calendarEvent);
|
||||
eventId = saved.Id;
|
||||
isExistingEvent = true;
|
||||
startTimeStr = saved.StartTime?.ToString("HH:mm");
|
||||
endTimeStr = saved.EndTime?.ToString("HH:mm");
|
||||
Navigation.NavigateTo($"/calendar/{selectedDate:yyyy-MM}");
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (!isExistingEvent || string.IsNullOrWhiteSpace(eventId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var confirmed = await JS.InvokeAsync<bool>("confirm", $"Delete calendar event '{description}' on {selectedDate:dddd d MMMM}?\nThis cannot be undone.");
|
||||
if (!confirmed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var deleted = await WorkDayService.DeleteCalendarEventAsync(selectedDate, eventId);
|
||||
if (deleted)
|
||||
{
|
||||
Navigation.NavigateTo($"/calendar/{selectedDate:yyyy-MM}");
|
||||
return;
|
||||
}
|
||||
|
||||
statusMessage = "Unable to delete the calendar event.";
|
||||
}
|
||||
|
||||
private void BackToCalendar()
|
||||
{
|
||||
Navigation.NavigateTo($"/calendar/{selectedDate:yyyy-MM}");
|
||||
}
|
||||
|
||||
private decimal? GetDuration()
|
||||
{
|
||||
var start = ParseTime(startTimeStr);
|
||||
var end = ParseTime(endTimeStr);
|
||||
if (!start.HasValue || !end.HasValue || end <= start)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.Round((decimal)(end.Value - start.Value).TotalHours, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private string FormatDuration() => GetDuration() is { } duration ? FormatDurationHours(duration) : "—";
|
||||
|
||||
private static string FormatDurationHours(decimal value)
|
||||
{
|
||||
var totalMinutes = (int)Math.Round(value * 60m, MidpointRounding.AwayFromZero);
|
||||
var hours = totalMinutes / 60;
|
||||
var minutes = totalMinutes % 60;
|
||||
return $"{hours:00}:{minutes:00}";
|
||||
}
|
||||
|
||||
private static TimeOnly? ParseTime(string? value)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(value) && TimeOnly.TryParse(value, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
@inject IWorkDayService WorkDayService
|
||||
@inject IItalianFestivitySource FestivitySource
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<PageTitle>Calendar</PageTitle>
|
||||
|
||||
|
|
@ -15,8 +16,14 @@
|
|||
<button class="btn btn-outline-secondary btn-sm" @onclick="PreviousMonth">« Prev</button>
|
||||
<h2 class="h5 mb-0">@firstOfMonth.ToString("MMMM yyyy")</h2>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="NextMonth">Next »</button>
|
||||
<button class="btn btn-primary btn-sm ms-auto" @onclick="GeneratePreviewWorkUnitsAsync">Generate Preview Units</button>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(statusMessage))
|
||||
{
|
||||
<div class="alert alert-info py-2">@statusMessage</div>
|
||||
}
|
||||
|
||||
@if (loading)
|
||||
{
|
||||
<p><em>Loading...</em></p>
|
||||
|
|
@ -48,12 +55,67 @@ else
|
|||
}
|
||||
else
|
||||
{
|
||||
<td class="calendar-cell @GetCellClass(cell)" @onclick="() => NavigateToDay(cell.Date)" role="button">
|
||||
<td class="calendar-cell @GetCellClass(cell) @(IsActiveCell(cell.Date) ? "calendar-cell-active" : string.Empty)" @onclick="() => TogglePopup(cell.Date)" role="button">
|
||||
<div class="calendar-day-number">@cell.Date.Day</div>
|
||||
@if (cell.Entry is not null)
|
||||
|
||||
@foreach (var workUnit in cell.Entry?.WorkUnits ?? [])
|
||||
{
|
||||
<span class="badge @GetBadgeClass(cell.Entry.DayType)">@cell.Entry.DayType</span>
|
||||
<div class="calendar-hours">@cell.Entry.WorkedHoursFinal.ToString("N1")h</div>
|
||||
<button type="button" class="calendar-item calendar-item-work @GetWorkUnitClass(workUnit)" @onclick:stopPropagation="true" @onclick="() => OpenWorkUnit(cell.Date, workUnit.Id)">
|
||||
<span>@workUnit.Label</span>
|
||||
<span>@FormatWorkUnit(workUnit)</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
@foreach (var calendarEvent in cell.Entry?.CalendarEvents ?? [])
|
||||
{
|
||||
<button type="button" class="calendar-item calendar-item-event @GetCalendarEventClass(calendarEvent)" @onclick:stopPropagation="true" @onclick="() => OpenCalendarEvent(cell.Date, calendarEvent.Id)">
|
||||
<span>@calendarEvent.Description</span>
|
||||
@if (calendarEvent.StartTime.HasValue)
|
||||
{
|
||||
<span>@calendarEvent.StartTime.Value.ToString("HH:mm")</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
|
||||
<div class="calendar-day-total">@FormatHours(GetDayTotalHours(cell.Entry, includePreviewTotals))</div>
|
||||
|
||||
@if (IsActiveCell(cell.Date))
|
||||
{
|
||||
<div class="calendar-popup @GetPopupClass(cell)" @onclick:stopPropagation="true">
|
||||
<div class="d-flex align-items-start justify-content-between gap-2 mb-2">
|
||||
<div>
|
||||
<div class="fw-semibold">@cell.Date.ToString("dddd d MMMM")</div>
|
||||
<div class="small text-muted">Select an existing entry or create a new one.</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-sm" aria-label="Close" @onclick="ClosePopup"></button>
|
||||
</div>
|
||||
|
||||
<div class="calendar-popup-section">
|
||||
@if ((cell.Entry?.WorkUnits.Count ?? 0) == 0 && (cell.Entry?.CalendarEvents.Count ?? 0) == 0)
|
||||
{
|
||||
<div class="small text-muted">No entries for this day.</div>
|
||||
}
|
||||
|
||||
@foreach (var workUnit in cell.Entry?.WorkUnits ?? [])
|
||||
{
|
||||
<button type="button" class="calendar-popup-link" @onclick="() => OpenWorkUnit(cell.Date, workUnit.Id)">
|
||||
Work unit: @workUnit.Label (@FormatWorkUnit(workUnit))
|
||||
</button>
|
||||
}
|
||||
|
||||
@foreach (var calendarEvent in cell.Entry?.CalendarEvents ?? [])
|
||||
{
|
||||
<button type="button" class="calendar-popup-link" @onclick="() => OpenCalendarEvent(cell.Date, calendarEvent.Id)">
|
||||
Calendar event: @calendarEvent.Description
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="() => CreateWorkUnit(cell.Date)">New Work Unit</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" @onclick="() => CreateCalendarEvent(cell.Date)">New Calendar Event</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
|
|
@ -67,14 +129,69 @@ else
|
|||
<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 calendar-legend-work">Office work unit</span>
|
||||
<span class="badge calendar-legend-home">Home work unit</span>
|
||||
<span class="badge calendar-legend-preview">Preview work unit</span>
|
||||
<span class="badge bg-warning text-dark">Closure</span>
|
||||
<span class="badge bg-info text-dark">Illness</span>
|
||||
<span class="badge bg-secondary">DayOff</span>
|
||||
<span class="badge bg-secondary">Day off</span>
|
||||
<span class="badge bg-danger">Holiday</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (monthTotals is not null)
|
||||
{
|
||||
<div class="calendar-month-summary mt-4">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3 mb-3">
|
||||
<h3 class="h6 mb-0">Month Totals</h3>
|
||||
<div class="form-check mb-0">
|
||||
<input id="calendar-include-preview" type="checkbox" class="form-check-input" checked="@includePreviewTotals" @onchange="OnIncludePreviewTotalsChanged" />
|
||||
<label class="form-check-label" for="calendar-include-preview">Include preview work units</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-6 col-md-4 col-xl-2">
|
||||
<div class="card text-center h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small">Worked Hours</div>
|
||||
<div class="fs-4 fw-bold">@FormatHours(monthTotals.TotalWorkedHours)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-xl-2">
|
||||
<div class="card text-center h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small">Counted Units</div>
|
||||
<div class="fs-4 fw-bold">@monthTotals.CountedWorkUnits</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-xl-2">
|
||||
<div class="card text-center h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small">Hours Off</div>
|
||||
<div class="fs-4 fw-bold">@FormatHours(monthTotals.TotalHoursOff)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
<div class="card text-center h-100 border-success">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small">Gross Income</div>
|
||||
<div class="fs-4 fw-bold text-success">€@monthTotals.TotalGrossIncome.ToString("N2")</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
<div class="card text-center h-100 border-primary">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small">Net Income</div>
|
||||
<div class="fs-4 fw-bold text-primary">€@monthTotals.TotalNetIncome.ToString("N2")</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
|
|
@ -84,6 +201,10 @@ else
|
|||
private bool loading = true;
|
||||
private List<CalendarCell?[]> weeks = [];
|
||||
private IReadOnlyCollection<DateOnly> festivities = [];
|
||||
private DateOnly? activeDate;
|
||||
private bool includePreviewTotals;
|
||||
private MonthlySummaryModel? monthTotals;
|
||||
private string? statusMessage;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
|
|
@ -102,11 +223,13 @@ else
|
|||
private async Task LoadMonth()
|
||||
{
|
||||
loading = true;
|
||||
activeDate = null;
|
||||
festivities = FestivitySource.GetFestivities(firstOfMonth.Year);
|
||||
|
||||
var lastDay = firstOfMonth.AddMonths(1).AddDays(-1);
|
||||
var entries = await WorkDayService.GetRangeAsync(firstOfMonth, lastDay);
|
||||
var lookup = entries.ToDictionary(e => e.Date);
|
||||
monthTotals = await WorkDayService.GetMonthlySummaryAsync(firstOfMonth.Year, firstOfMonth.Month, includePreviewTotals);
|
||||
|
||||
// Build calendar grid (ISO weeks: Monday = 0)
|
||||
weeks = [];
|
||||
|
|
@ -118,6 +241,7 @@ else
|
|||
currentWeek[dayOfWeek] = new CalendarCell
|
||||
{
|
||||
Date = d,
|
||||
ColumnIndex = dayOfWeek,
|
||||
IsWeekend = d.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday,
|
||||
IsFestivity = festivities.Contains(d),
|
||||
Entry = lookup.GetValueOrDefault(d)
|
||||
|
|
@ -140,49 +264,191 @@ else
|
|||
loading = false;
|
||||
}
|
||||
|
||||
private async Task OnIncludePreviewTotalsChanged(ChangeEventArgs e)
|
||||
{
|
||||
includePreviewTotals = e.Value is bool value && value;
|
||||
await LoadMonth();
|
||||
}
|
||||
|
||||
private async Task PreviousMonth()
|
||||
{
|
||||
firstOfMonth = firstOfMonth.AddMonths(-1);
|
||||
statusMessage = null;
|
||||
await LoadMonth();
|
||||
}
|
||||
|
||||
private async Task NextMonth()
|
||||
{
|
||||
firstOfMonth = firstOfMonth.AddMonths(1);
|
||||
statusMessage = null;
|
||||
await LoadMonth();
|
||||
}
|
||||
|
||||
private void NavigateToDay(DateOnly date) =>
|
||||
Navigation.NavigateTo($"/workday/{date:yyyy-MM-dd}");
|
||||
private void TogglePopup(DateOnly date)
|
||||
{
|
||||
activeDate = activeDate == date ? null : date;
|
||||
}
|
||||
|
||||
private void ClosePopup()
|
||||
{
|
||||
activeDate = null;
|
||||
}
|
||||
|
||||
private static string GetPopupClass(CalendarCell cell)
|
||||
{
|
||||
if (cell.ColumnIndex == 0)
|
||||
{
|
||||
return "calendar-popup-left";
|
||||
}
|
||||
|
||||
return cell.ColumnIndex >= 5 ? "calendar-popup-right" : string.Empty;
|
||||
}
|
||||
|
||||
private bool IsActiveCell(DateOnly date) => activeDate == date;
|
||||
|
||||
private void CreateWorkUnit(DateOnly date)
|
||||
{
|
||||
Navigation.NavigateTo($"/work-unit/{date:yyyy-MM-dd}");
|
||||
}
|
||||
|
||||
private void CreateCalendarEvent(DateOnly date)
|
||||
{
|
||||
Navigation.NavigateTo($"/calendar-event/{date:yyyy-MM-dd}");
|
||||
}
|
||||
|
||||
private void OpenWorkUnit(DateOnly date, string workUnitId)
|
||||
{
|
||||
Navigation.NavigateTo($"/work-unit/{date:yyyy-MM-dd}/{workUnitId}");
|
||||
}
|
||||
|
||||
private void OpenCalendarEvent(DateOnly date, string eventId)
|
||||
{
|
||||
Navigation.NavigateTo($"/calendar-event/{date:yyyy-MM-dd}/{eventId}");
|
||||
}
|
||||
|
||||
private async Task GeneratePreviewWorkUnitsAsync()
|
||||
{
|
||||
var confirmed = await JS.InvokeAsync<bool>("confirm", $"Generate preview work units for {firstOfMonth:MMMM yyyy}? Existing work-unit days will be left unchanged.");
|
||||
if (!confirmed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var createdDays = await WorkDayService.GenerateMonthlyPreviewWorkUnitsAsync(firstOfMonth.Year, firstOfMonth.Month);
|
||||
statusMessage = createdDays == 0
|
||||
? "No preview work units were created. Every eligible day already had work units or a blocking calendar event."
|
||||
: $"Created preview work units for {createdDays} day(s).";
|
||||
await LoadMonth();
|
||||
}
|
||||
|
||||
private string GetCellClass(CalendarCell cell)
|
||||
{
|
||||
if (cell.IsWeekend || cell.IsFestivity) return "calendar-weekend";
|
||||
if (cell.Entry is null) return "";
|
||||
return cell.Entry.DayType switch
|
||||
if (cell.IsWeekend || cell.IsFestivity)
|
||||
{
|
||||
DayType.Closure => "calendar-closure",
|
||||
DayType.Illness => "calendar-illness",
|
||||
DayType.DayOff => "calendar-dayoff",
|
||||
DayType.Holiday => "calendar-holiday",
|
||||
_ => ""
|
||||
};
|
||||
return "calendar-weekend";
|
||||
}
|
||||
|
||||
var eventType = GetDominantEventType(cell.Entry);
|
||||
if (eventType.HasValue)
|
||||
{
|
||||
return eventType.Value switch
|
||||
{
|
||||
CalendarEventType.Closure => "calendar-closure",
|
||||
CalendarEventType.Illness => "calendar-illness",
|
||||
CalendarEventType.DayOff => "calendar-dayoff",
|
||||
CalendarEventType.Holiday => "calendar-holiday",
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static string GetBadgeClass(DayType type) => type switch
|
||||
private static CalendarEventType? GetDominantEventType(WorkDayDocument? day)
|
||||
{
|
||||
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"
|
||||
if (day is null || day.CalendarEvents.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (day.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.Holiday))
|
||||
{
|
||||
return CalendarEventType.Holiday;
|
||||
}
|
||||
|
||||
if (day.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.Closure))
|
||||
{
|
||||
return CalendarEventType.Closure;
|
||||
}
|
||||
|
||||
if (day.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.DayOff))
|
||||
{
|
||||
return CalendarEventType.DayOff;
|
||||
}
|
||||
|
||||
if (day.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.Illness))
|
||||
{
|
||||
return CalendarEventType.Illness;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string GetWorkUnitClass(WorkUnitDocument workUnit)
|
||||
{
|
||||
if (workUnit.IsPreview)
|
||||
{
|
||||
return workUnit.Location == WorkUnitLocation.Home
|
||||
? "calendar-item-preview-home"
|
||||
: "calendar-item-preview-office";
|
||||
}
|
||||
|
||||
return workUnit.Location == WorkUnitLocation.Home ? "calendar-item-home" : "calendar-item-office";
|
||||
}
|
||||
|
||||
private static string GetCalendarEventClass(CalendarEventDocument calendarEvent) => calendarEvent.EventType switch
|
||||
{
|
||||
CalendarEventType.Holiday => "calendar-item-holiday",
|
||||
CalendarEventType.Closure => "calendar-item-closure",
|
||||
CalendarEventType.DayOff => "calendar-item-dayoff",
|
||||
CalendarEventType.Illness => "calendar-item-illness",
|
||||
_ => "calendar-item-generic"
|
||||
};
|
||||
|
||||
private static string FormatWorkUnit(WorkUnitDocument workUnit)
|
||||
{
|
||||
var hours = FormatHours(workUnit.ManualWorkedHours);
|
||||
var timeRange = workUnit.StartTime.HasValue && workUnit.EndTime.HasValue
|
||||
? $"{workUnit.StartTime:HH:mm}-{workUnit.EndTime:HH:mm}"
|
||||
: hours;
|
||||
|
||||
return workUnit.IsPreview ? $"{timeRange} preview" : timeRange;
|
||||
}
|
||||
|
||||
private static decimal GetDayTotalHours(WorkDayDocument? day, bool includePreview)
|
||||
{
|
||||
if (day is null)
|
||||
{
|
||||
return 0m;
|
||||
}
|
||||
|
||||
return day.WorkUnits
|
||||
.Where(unit => includePreview || !unit.IsPreview)
|
||||
.Sum(unit => unit.ManualWorkedHours);
|
||||
}
|
||||
|
||||
private static string FormatHours(decimal value)
|
||||
{
|
||||
var totalMinutes = (int)Math.Round(value * 60m, MidpointRounding.AwayFromZero);
|
||||
var hours = totalMinutes / 60;
|
||||
var minutes = totalMinutes % 60;
|
||||
return $"{hours:00}:{minutes:00}";
|
||||
}
|
||||
|
||||
private sealed class CalendarCell
|
||||
{
|
||||
public DateOnly Date { get; set; }
|
||||
public int ColumnIndex { get; set; }
|
||||
public bool IsWeekend { get; set; }
|
||||
public bool IsFestivity { get; set; }
|
||||
public WorkDayDocument? Entry { get; set; }
|
||||
|
|
|
|||
|
|
@ -29,12 +29,10 @@ else
|
|||
<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>Work Units</th>
|
||||
<th>Calendar Events</th>
|
||||
<th class="text-end">Counted</th>
|
||||
<th class="text-end">Preview</th>
|
||||
<th class="text-end">Off</th>
|
||||
<th class="text-end">Gross €</th>
|
||||
<th class="text-end">Net €</th>
|
||||
|
|
@ -47,24 +45,42 @@ else
|
|||
<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>
|
||||
@if (row.Entry?.WorkUnits.Count > 0)
|
||||
{
|
||||
@foreach (var unit in row.Entry.WorkUnits)
|
||||
{
|
||||
<div class="small mb-1">@unit.Label: @FormatTimeRange(unit.StartTime, unit.EndTime) (@FormatHours(unit.ManualWorkedHours)@(unit.IsPreview ? ", preview" : ""))</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (row.Entry?.CalendarEvents.Count > 0)
|
||||
{
|
||||
@foreach (var calendarEvent in row.Entry.CalendarEvents)
|
||||
{
|
||||
<div class="small mb-1">@calendarEvent.EventType: @calendarEvent.Description</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end">@FormatHours(GetCountedHours(row))</td>
|
||||
<td class="text-end">@FormatHours(GetPreviewHours(row))</td>
|
||||
<td class="text-end">@FormatHours(GetHoursOff(row))</td>
|
||||
<td class="text-end">@GetGrossIncome(row).ToString("N2")</td>
|
||||
<td class="text-end">@GetNetIncome(row).ToString("N2")</td>
|
||||
<td>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="work-unit/@row.Date.ToString("yyyy-MM-dd")" class="btn btn-sm btn-outline-primary">Unit</a>
|
||||
<a href="calendar-event/@row.Date.ToString("yyyy-MM-dd")" class="btn btn-sm btn-outline-secondary">Event</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
|
@ -136,23 +152,93 @@ else
|
|||
{
|
||||
if (row.IsWeekend || row.IsFestivity) return "table-danger";
|
||||
if (row.Entry is null) return "";
|
||||
return row.Entry.DayType switch
|
||||
if (row.Entry.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.Holiday))
|
||||
{
|
||||
DayType.Closure => "table-warning",
|
||||
DayType.Illness => "table-info",
|
||||
DayType.DayOff => "table-secondary",
|
||||
DayType.Holiday => "table-success",
|
||||
DayType.Home => "table-light",
|
||||
_ => ""
|
||||
};
|
||||
return "table-success";
|
||||
}
|
||||
|
||||
if (row.Entry.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.Closure))
|
||||
{
|
||||
return "table-warning";
|
||||
}
|
||||
|
||||
if (row.Entry.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.Illness))
|
||||
{
|
||||
return "table-info";
|
||||
}
|
||||
|
||||
if (row.Entry.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.DayOff))
|
||||
{
|
||||
return "table-secondary";
|
||||
}
|
||||
|
||||
if (row.Entry.WorkUnits.Any(entry => entry.Location == WorkUnitLocation.Home))
|
||||
{
|
||||
return "table-light";
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static string FormatDelta(decimal d) => d switch
|
||||
private static decimal GetCountedHours(CalendarDayRow row)
|
||||
{
|
||||
> 0 => $"+{d:N2}",
|
||||
< 0 => d.ToString("N2"),
|
||||
_ => "—"
|
||||
};
|
||||
return row.Entry?.WorkUnits.Where(unit => !unit.IsPreview).Sum(unit => unit.ManualWorkedHours) ?? 0m;
|
||||
}
|
||||
|
||||
private static decimal GetPreviewHours(CalendarDayRow row)
|
||||
{
|
||||
return row.Entry?.WorkUnits.Where(unit => unit.IsPreview).Sum(unit => unit.ManualWorkedHours) ?? 0m;
|
||||
}
|
||||
|
||||
private static decimal GetHoursOff(CalendarDayRow row)
|
||||
{
|
||||
if (row.Entry is null || row.Entry.WorkUnits.Count == 0)
|
||||
{
|
||||
return 0m;
|
||||
}
|
||||
|
||||
var countedUnits = row.Entry.WorkUnits.Where(unit => !unit.IsPreview).ToList();
|
||||
if (countedUnits.Count == 0)
|
||||
{
|
||||
return 0m;
|
||||
}
|
||||
|
||||
var standardHours = countedUnits[0].CoeffSnapshot.StandardWorkHoursPerDay;
|
||||
return Math.Max(0m, standardHours - countedUnits.Sum(unit => unit.ManualWorkedHours));
|
||||
}
|
||||
|
||||
private static decimal GetGrossIncome(CalendarDayRow row)
|
||||
{
|
||||
return row.Entry?.WorkUnits.Where(unit => !unit.IsPreview).Sum(unit => unit.GrossIncome) ?? 0m;
|
||||
}
|
||||
|
||||
private static decimal GetNetIncome(CalendarDayRow row)
|
||||
{
|
||||
return row.Entry?.WorkUnits.Where(unit => !unit.IsPreview).Sum(unit => unit.NetIncome) ?? 0m;
|
||||
}
|
||||
|
||||
private static string FormatTimeRange(TimeOnly? startTime, TimeOnly? endTime)
|
||||
{
|
||||
if (startTime.HasValue && endTime.HasValue)
|
||||
{
|
||||
return $"{startTime:HH:mm}-{endTime:HH:mm}";
|
||||
}
|
||||
|
||||
if (startTime.HasValue)
|
||||
{
|
||||
return startTime.Value.ToString("HH:mm");
|
||||
}
|
||||
|
||||
return "No time range";
|
||||
}
|
||||
|
||||
private static string FormatHours(decimal value)
|
||||
{
|
||||
var totalMinutes = (int)Math.Round(value * 60m, MidpointRounding.AwayFromZero);
|
||||
var hours = totalMinutes / 60;
|
||||
var minutes = totalMinutes % 60;
|
||||
return $"{hours:00}:{minutes:00}";
|
||||
}
|
||||
|
||||
private sealed class CalendarDayRow
|
||||
{
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@
|
|||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<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>
|
||||
<h2 class="h5 card-title">Calendar</h2>
|
||||
<p class="mb-2">Open the calendar and create work units or calendar events from each day cell.</p>
|
||||
<a href="calendar" class="btn btn-primary">Open Calendar</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -26,9 +26,9 @@
|
|||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<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>
|
||||
<h2 class="h5 card-title">Grid View</h2>
|
||||
<p class="mb-2">Tabular view of daily work-unit and calendar-event details.</p>
|
||||
<a href="grid" class="btn btn-outline-primary">Open Grid</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -36,7 +36,7 @@
|
|||
<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>
|
||||
<p class="mb-2">Totals for counted hours, preview hours, income, and non-working events.</p>
|
||||
<a href="summary" class="btn btn-outline-primary">Open Summary</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -45,7 +45,7 @@
|
|||
<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>
|
||||
<p class="mb-2">Configure the standard daily target and the income coefficients.</p>
|
||||
<a href="settings" class="btn btn-outline-secondary">Open Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
@attribute [Authorize]
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@inject IWorkDayService WorkDayService
|
||||
@using System.Globalization
|
||||
@inject global::WorkTracker.Services.WorkDays.IWorkDayService WorkDayService
|
||||
|
||||
<PageTitle>Monthly Summary</PageTitle>
|
||||
|
||||
|
|
@ -15,11 +16,21 @@
|
|||
<button class="btn btn-outline-secondary btn-sm" @onclick="NextMonth">Next »</button>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input id="include-preview" type="checkbox" class="form-check-input" checked="@includePreview" @onchange="OnIncludePreviewChanged" />
|
||||
<label class="form-check-label" for="include-preview">Include preview work units in totals</label>
|
||||
</div>
|
||||
|
||||
<div class="btn-group mb-3" role="group" aria-label="Summary view selector">
|
||||
<button type="button" class="btn @(viewMode == SummaryViewMode.Cards ? "btn-primary" : "btn-outline-primary")" @onclick="() => SetViewMode(SummaryViewMode.Cards)">Cards</button>
|
||||
<button type="button" class="btn @(viewMode == SummaryViewMode.Timesheet ? "btn-primary" : "btn-outline-primary")" @onclick="() => SetViewMode(SummaryViewMode.Timesheet)">Timesheet</button>
|
||||
</div>
|
||||
|
||||
@if (loading)
|
||||
{
|
||||
<p><em>Loading...</em></p>
|
||||
}
|
||||
else if (summary is not null)
|
||||
else if (viewMode == SummaryViewMode.Cards && summary is not null)
|
||||
{
|
||||
<div class="row g-3">
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
|
|
@ -30,11 +41,35 @@ else if (summary is not null)
|
|||
</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">Counted Work Units</div>
|
||||
<div class="fs-3 fw-bold">@summary.CountedWorkUnits</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 class="fs-3 fw-bold">@FormatHours(summary.TotalWorkedHours)</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">Preview Hours</div>
|
||||
<div class="fs-3 fw-bold">@FormatHours(summary.TotalPreviewWorkedHours)</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">Preview Units</div>
|
||||
<div class="fs-3 fw-bold">@summary.PreviewWorkUnits</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -42,7 +77,7 @@ else if (summary is not null)
|
|||
<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 class="fs-3 fw-bold">@FormatHours(summary.TotalHoursOff)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -89,8 +124,8 @@ else if (summary is not null)
|
|||
<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 class="text-muted small">Closure Days</div>
|
||||
<div class="fs-3 fw-bold">@summary.ClosureDays</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -105,20 +140,82 @@ else if (summary is not null)
|
|||
<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 class="text-muted small">Sick Days</div>
|
||||
<div class="fs-3 fw-bold">@summary.SickDays</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (viewMode == SummaryViewMode.Timesheet && timesheet is not null)
|
||||
{
|
||||
<div class="timesheet-summary-card card border-0 shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-sm align-middle mb-0 timesheet-summary-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="timesheet-summary-sticky-column">Categoria</th>
|
||||
@for (var i = 0; i < timesheet.Days.Count; i++)
|
||||
{
|
||||
var day = timesheet.Days[i];
|
||||
<th class="text-center timesheet-summary-day-header @GetDayColumnClass(day) @GetDayPopupClass(i, timesheet.Days.Count)">
|
||||
<div>@day.Date.Day</div>
|
||||
<div class="small text-muted">@GetDayHeader(day.Date)</div>
|
||||
<div class="timesheet-summary-day-popup">
|
||||
<div class="fw-semibold mb-2">@day.Date.ToString("dddd d MMMM", ItalianCulture)</div>
|
||||
@if (day.WorkUnitSummaries.Count == 0 && day.EventSummaries.Count == 0)
|
||||
{
|
||||
<div class="small text-muted">Nessun elemento registrato.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var workUnit in day.WorkUnitSummaries)
|
||||
{
|
||||
<div class="timesheet-summary-day-popup-item">@workUnit</div>
|
||||
}
|
||||
|
||||
@foreach (var calendarEvent in day.EventSummaries)
|
||||
{
|
||||
<div class="timesheet-summary-day-popup-item timesheet-summary-day-popup-item-event">@calendarEvent</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</th>
|
||||
}
|
||||
<th class="text-center timesheet-summary-total-column">Totale</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var row in timesheet.Rows)
|
||||
{
|
||||
<tr>
|
||||
<th scope="row" class="timesheet-summary-sticky-column">@row.Label</th>
|
||||
@for (var i = 0; i < row.DailyValues.Count; i++)
|
||||
{
|
||||
<td class="text-center @GetDayColumnClass(timesheet.Days[i])">@FormatTimesheetValue(row.DailyValues[i], row.ValueFormat)</td>
|
||||
}
|
||||
<td class="text-center fw-semibold timesheet-summary-total-column">@FormatTimesheetValue(row.Total, row.ValueFormat)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string? YearMonth { get; set; }
|
||||
|
||||
private static readonly CultureInfo ItalianCulture = CultureInfo.GetCultureInfo("it-IT");
|
||||
|
||||
private DateOnly currentMonth;
|
||||
private bool loading = true;
|
||||
private MonthlySummaryModel? summary;
|
||||
private bool includePreview;
|
||||
private global::WorkTracker.Domain.MonthlySummaryModel? summary;
|
||||
private global::WorkTracker.Domain.MonthlyTimesheetModel? timesheet;
|
||||
private SummaryViewMode viewMode = SummaryViewMode.Timesheet;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
|
|
@ -134,10 +231,17 @@ else if (summary is not null)
|
|||
await LoadSummary();
|
||||
}
|
||||
|
||||
private async Task OnIncludePreviewChanged(ChangeEventArgs e)
|
||||
{
|
||||
includePreview = e.Value is bool value && value;
|
||||
await LoadSummary();
|
||||
}
|
||||
|
||||
private async Task LoadSummary()
|
||||
{
|
||||
loading = true;
|
||||
summary = await WorkDayService.GetMonthlySummaryAsync(currentMonth.Year, currentMonth.Month);
|
||||
summary = await WorkDayService.GetMonthlySummaryAsync(currentMonth.Year, currentMonth.Month, includePreview);
|
||||
timesheet = await WorkDayService.GetMonthlyTimesheetAsync(currentMonth.Year, currentMonth.Month, includePreview);
|
||||
loading = false;
|
||||
}
|
||||
|
||||
|
|
@ -152,4 +256,65 @@ else if (summary is not null)
|
|||
currentMonth = currentMonth.AddMonths(1);
|
||||
await LoadSummary();
|
||||
}
|
||||
|
||||
private void SetViewMode(SummaryViewMode mode)
|
||||
{
|
||||
viewMode = mode;
|
||||
}
|
||||
|
||||
private static string GetDayHeader(DateOnly date)
|
||||
{
|
||||
return ItalianCulture.TextInfo.ToTitleCase(date.ToString("ddd", ItalianCulture));
|
||||
}
|
||||
|
||||
private static string GetDayColumnClass(global::WorkTracker.Domain.MonthlyTimesheetDayModel day)
|
||||
{
|
||||
if (day.IsWeekend || day.IsHoliday)
|
||||
{
|
||||
return "timesheet-summary-day-danger";
|
||||
}
|
||||
|
||||
return day.IsClosure ? "timesheet-summary-day-closure" : string.Empty;
|
||||
}
|
||||
|
||||
private static string GetDayPopupClass(int index, int totalDays)
|
||||
{
|
||||
if (index == 0)
|
||||
{
|
||||
return "timesheet-summary-day-popup-left";
|
||||
}
|
||||
|
||||
return index >= totalDays - 2 ? "timesheet-summary-day-popup-right" : string.Empty;
|
||||
}
|
||||
|
||||
private static string FormatTimesheetValue(decimal? value, global::WorkTracker.Domain.MonthlyTimesheetValueFormat valueFormat)
|
||||
{
|
||||
if (!value.HasValue || value.Value <= 0m)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return valueFormat == global::WorkTracker.Domain.MonthlyTimesheetValueFormat.Days
|
||||
? value.Value.ToString("0.##", ItalianCulture)
|
||||
: FormatDecimalHours(value.Value);
|
||||
}
|
||||
|
||||
private static string FormatDecimalHours(decimal value)
|
||||
{
|
||||
return value.ToString("0.##", ItalianCulture);
|
||||
}
|
||||
|
||||
private static string FormatHours(decimal value)
|
||||
{
|
||||
var totalMinutes = (int)Math.Round(value * 60m, MidpointRounding.AwayFromZero);
|
||||
var hours = totalMinutes / 60;
|
||||
var minutes = totalMinutes % 60;
|
||||
return $"{hours:00}:{minutes:00}";
|
||||
}
|
||||
|
||||
private enum SummaryViewMode
|
||||
{
|
||||
Cards,
|
||||
Timesheet
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<PageTitle>Settings</PageTitle>
|
||||
|
||||
<h1>Settings</h1>
|
||||
<p class="text-muted">Default values used to prefill each workday. Every day can still override these values.</p>
|
||||
<p class="text-muted">Default values used to compute manual work-unit totals and income.</p>
|
||||
|
||||
@if (settings is null)
|
||||
{
|
||||
|
|
@ -22,10 +22,6 @@ else
|
|||
<label class="form-label">Standard work hours/day</label>
|
||||
<InputNumber class="form-control" @bind-Value="settings.StandardWorkHoursPerDay" />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">Lunch break hours</label>
|
||||
<InputNumber class="form-control" @bind-Value="settings.LunchBreakHours" />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">Hourly gross rate (€)</label>
|
||||
<InputNumber class="form-control" @bind-Value="settings.HourlyGrossRate" />
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
@page "/workday"
|
||||
@page "/workday/{DateStr}"
|
||||
@page "/work-unit"
|
||||
@page "/work-unit/{DateStr}"
|
||||
@page "/work-unit/{DateStr}/{UnitId}"
|
||||
@attribute [Authorize]
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@inject IWorkDayService WorkDayService
|
||||
@inject IAppSettingsService AppSettingsService
|
||||
@inject IItalianFestivitySource FestivitySource
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<PageTitle>Work Day</PageTitle>
|
||||
<PageTitle>Work Unit</PageTitle>
|
||||
|
||||
<h1>Work Day Entry</h1>
|
||||
<h1>Work Unit</h1>
|
||||
|
||||
@if (!loaded)
|
||||
{
|
||||
|
|
@ -21,22 +22,20 @@ 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>
|
||||
}
|
||||
<input type="date" class="form-control" value="@selectedDate.ToString("yyyy-MM-dd")" @onchange="OnDateChanged" disabled="@isExistingUnit" />
|
||||
</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>())
|
||||
<label class="form-label">Label</label>
|
||||
<input class="form-control" @bind="label" maxlength="40" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<label class="form-label">Location</label>
|
||||
<select class="form-select" @bind="location">
|
||||
@foreach (var item in Enum.GetValues<WorkUnitLocation>())
|
||||
{
|
||||
<option value="@dt">@dt</option>
|
||||
<option value="@item">@item</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
|
@ -47,14 +46,20 @@ else
|
|||
</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>
|
||||
<label class="form-label">End Time</label>
|
||||
<input type="time" class="form-control" value="@endTimeStr" @onchange="OnEndTimeChanged" />
|
||||
</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" />
|
||||
<label class="form-label">Counted Hours</label>
|
||||
<input type="text" class="form-control" value="@manualWorkedHoursStr" @onchange="OnManualWorkedHoursChanged" placeholder="00:00" inputmode="numeric" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 col-lg-4 d-flex align-items-end">
|
||||
<div class="form-check mb-2">
|
||||
<input id="preview-checkbox" type="checkbox" class="form-check-input" @bind="isPreview" />
|
||||
<label class="form-check-label" for="preview-checkbox">Preview work unit</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
|
|
@ -68,20 +73,12 @@ else
|
|||
<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>
|
||||
<label class="form-label text-muted">Calculated Hours</label>
|
||||
<div class="form-control-plaintext fw-bold">@FormatHours(calculatedWorkedHours)</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>
|
||||
<label class="form-label text-muted">Difference</label>
|
||||
<div class="form-control-plaintext fw-bold">@FormatSignedHours(workedHoursDelta)</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-lg-3">
|
||||
<label class="form-label text-muted">Gross Income</label>
|
||||
|
|
@ -93,8 +90,27 @@ else
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<h2 class="h5">Day Total</h2>
|
||||
<div class="row g-3">
|
||||
<div class="col-6 col-md-4 col-lg-3">
|
||||
<label class="form-label text-muted">Total Hours For Day</label>
|
||||
<div class="form-control-plaintext fw-bold">@FormatHours(dayTotalHours)</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-lg-3">
|
||||
<label class="form-label text-muted">Work Units Counted</label>
|
||||
<div class="form-control-plaintext fw-bold">@dayWorkUnitCount</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center gap-2 mt-4">
|
||||
<button class="btn btn-primary" @onclick="SaveAsync">Save</button>
|
||||
@if (isExistingUnit)
|
||||
{
|
||||
<button class="btn btn-outline-danger" @onclick="DeleteAsync">Delete</button>
|
||||
}
|
||||
<button class="btn btn-outline-secondary" @onclick="BackToCalendar">Back to Calendar</button>
|
||||
@if (!string.IsNullOrWhiteSpace(statusMessage))
|
||||
{
|
||||
<span class="text-success">@statusMessage</span>
|
||||
|
|
@ -104,29 +120,31 @@ else
|
|||
|
||||
@code {
|
||||
[Parameter] public string? DateStr { get; set; }
|
||||
[Parameter] public string? UnitId { get; set; }
|
||||
|
||||
private bool loaded;
|
||||
private DateOnly selectedDate = DateOnly.FromDateTime(DateTime.Today);
|
||||
private DayType selectedDayType = DayType.None;
|
||||
private string unitId = string.Empty;
|
||||
private string label = "Work unit";
|
||||
private WorkUnitLocation location = WorkUnitLocation.Office;
|
||||
private string? startTimeStr;
|
||||
private string? actualExitTimeStr;
|
||||
private decimal extraHoursDelta;
|
||||
private string? endTimeStr;
|
||||
private decimal manualWorkedHours;
|
||||
private string manualWorkedHoursStr = "00:00";
|
||||
private bool isPreview;
|
||||
private string? notes;
|
||||
private string? statusMessage;
|
||||
private bool isExistingUnit;
|
||||
private WorkDayDocument? selectedDay;
|
||||
|
||||
// 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;
|
||||
private decimal? calculatedWorkedHours;
|
||||
private decimal workedHoursDelta;
|
||||
private decimal dayTotalHours;
|
||||
private int dayWorkUnitCount;
|
||||
|
||||
// Loaded from settings
|
||||
private AppSettingsDocument settings = new();
|
||||
private IReadOnlyCollection<DateOnly> festivities = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
|
|
@ -136,33 +154,41 @@ else
|
|||
}
|
||||
|
||||
settings = await AppSettingsService.GetAsync();
|
||||
festivities = FestivitySource.GetFestivities(selectedDate.Year);
|
||||
|
||||
await LoadExistingEntry();
|
||||
RecomputeFlags();
|
||||
RecomputePreview();
|
||||
await LoadUnitAsync();
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
private async Task LoadExistingEntry()
|
||||
private async Task LoadUnitAsync()
|
||||
{
|
||||
var existing = await WorkDayService.GetAsync(selectedDate);
|
||||
if (string.IsNullOrWhiteSpace(UnitId))
|
||||
{
|
||||
selectedDay = await WorkDayService.GetAsync(selectedDate);
|
||||
SetDefaults();
|
||||
return;
|
||||
}
|
||||
|
||||
selectedDay = await WorkDayService.GetAsync(selectedDate);
|
||||
var existing = await WorkDayService.GetWorkUnitAsync(selectedDate, UnitId);
|
||||
if (existing is not null)
|
||||
{
|
||||
selectedDayType = existing.DayType;
|
||||
unitId = existing.Id;
|
||||
label = existing.Label;
|
||||
location = existing.Location;
|
||||
startTimeStr = existing.StartTime?.ToString("HH:mm");
|
||||
actualExitTimeStr = existing.ActualExitTime?.ToString("HH:mm");
|
||||
extraHoursDelta = existing.ExtraHoursDelta;
|
||||
endTimeStr = existing.EndTime?.ToString("HH:mm");
|
||||
manualWorkedHours = existing.ManualWorkedHours;
|
||||
manualWorkedHoursStr = FormatDurationHours(existing.ManualWorkedHours);
|
||||
isPreview = existing.IsPreview;
|
||||
notes = existing.Notes;
|
||||
isExistingUnit = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
selectedDayType = DayType.None;
|
||||
startTimeStr = null;
|
||||
actualExitTimeStr = null;
|
||||
extraHoursDelta = 0;
|
||||
notes = null;
|
||||
SetDefaults();
|
||||
statusMessage = "The selected work unit was not found. A new unit will be created for this day.";
|
||||
}
|
||||
|
||||
RecomputePreview();
|
||||
}
|
||||
|
||||
private async Task OnDateChanged(ChangeEventArgs e)
|
||||
|
|
@ -170,127 +196,214 @@ else
|
|||
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)
|
||||
private Task OnStartTimeChanged(ChangeEventArgs e)
|
||||
{
|
||||
startTimeStr = e.Value?.ToString();
|
||||
RecomputePreview();
|
||||
SyncManualHoursToCalculated();
|
||||
statusMessage = null;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void OnActualExitChanged(ChangeEventArgs e)
|
||||
private Task OnEndTimeChanged(ChangeEventArgs e)
|
||||
{
|
||||
actualExitTimeStr = e.Value?.ToString();
|
||||
endTimeStr = e.Value?.ToString();
|
||||
SyncManualHoursToCalculated();
|
||||
statusMessage = null;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void OnExtraDeltaChanged(ChangeEventArgs e)
|
||||
private Task OnManualWorkedHoursChanged(ChangeEventArgs e)
|
||||
{
|
||||
if (decimal.TryParse(e.Value?.ToString(), out var val))
|
||||
var rawValue = e.Value?.ToString();
|
||||
if (TryParseDurationHours(rawValue, out var parsedHours))
|
||||
{
|
||||
extraHoursDelta = val;
|
||||
manualWorkedHours = parsedHours;
|
||||
manualWorkedHoursStr = FormatDurationHours(parsedHours);
|
||||
}
|
||||
else
|
||||
{
|
||||
manualWorkedHoursStr = FormatDurationHours(manualWorkedHours);
|
||||
}
|
||||
|
||||
RecomputePreview();
|
||||
statusMessage = null;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void RecomputeFlags()
|
||||
private void SetDefaults()
|
||||
{
|
||||
isWeekend = selectedDate.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
|
||||
isFestivity = festivities.Contains(selectedDate);
|
||||
unitId = string.Empty;
|
||||
label = "Work unit";
|
||||
location = WorkUnitLocation.Office;
|
||||
startTimeStr = null;
|
||||
endTimeStr = null;
|
||||
manualWorkedHours = 0m;
|
||||
manualWorkedHoursStr = "00:00";
|
||||
isPreview = false;
|
||||
notes = null;
|
||||
isExistingUnit = false;
|
||||
RecomputePreview();
|
||||
}
|
||||
|
||||
private void SyncManualHoursToCalculated()
|
||||
{
|
||||
var calculated = CalculateDuration(ParseTime(startTimeStr), ParseTime(endTimeStr));
|
||||
manualWorkedHours = calculated ?? 0m;
|
||||
manualWorkedHoursStr = FormatDurationHours(manualWorkedHours);
|
||||
RecomputePreview();
|
||||
}
|
||||
|
||||
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;
|
||||
calculatedWorkedHours = CalculateDuration(ParseTime(startTimeStr), ParseTime(endTimeStr));
|
||||
workedHoursDelta = manualWorkedHours - (calculatedWorkedHours ?? 0m);
|
||||
grossIncome = manualWorkedHours * settings.HourlyGrossRate;
|
||||
var taxableBase = grossIncome * settings.ProfitabilityCoefficient;
|
||||
netIncome = grossIncome - (taxableBase * settings.InpsRate) - (taxableBase * settings.SubstituteTaxRate);
|
||||
RecomputeDayTotals();
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
TimeOnly? start = null;
|
||||
TimeOnly? exit = null;
|
||||
RecomputePreview();
|
||||
|
||||
if (!string.IsNullOrEmpty(startTimeStr) && TimeOnly.TryParse(startTimeStr, out var s))
|
||||
var workUnit = new WorkUnitDocument
|
||||
{
|
||||
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,
|
||||
Id = unitId,
|
||||
Label = label,
|
||||
Location = location,
|
||||
StartTime = ParseTime(startTimeStr),
|
||||
EndTime = ParseTime(endTimeStr),
|
||||
ManualWorkedHours = Math.Max(0m, manualWorkedHours),
|
||||
IsPreview = isPreview,
|
||||
Notes = notes
|
||||
};
|
||||
|
||||
var saved = await WorkDayService.SaveAsync(workDay);
|
||||
var saved = await WorkDayService.SaveWorkUnitAsync(selectedDate, workUnit);
|
||||
|
||||
// Update preview with saved computed values
|
||||
projectedExitTime = saved.ProjectedExitTime;
|
||||
workedHoursBase = saved.WorkedHoursBase;
|
||||
workedHoursFinal = saved.WorkedHoursFinal;
|
||||
hoursOff = saved.HoursOff;
|
||||
unitId = saved.Id;
|
||||
isExistingUnit = true;
|
||||
label = saved.Label;
|
||||
location = saved.Location;
|
||||
startTimeStr = saved.StartTime?.ToString("HH:mm");
|
||||
endTimeStr = saved.EndTime?.ToString("HH:mm");
|
||||
manualWorkedHours = saved.ManualWorkedHours;
|
||||
manualWorkedHoursStr = FormatDurationHours(saved.ManualWorkedHours);
|
||||
isPreview = saved.IsPreview;
|
||||
notes = saved.Notes;
|
||||
calculatedWorkedHours = saved.CalculatedWorkedHours;
|
||||
workedHoursDelta = saved.WorkedHoursDelta;
|
||||
grossIncome = saved.GrossIncome;
|
||||
netIncome = saved.NetIncome;
|
||||
isWeekend = saved.IsWeekend;
|
||||
isFestivity = saved.IsItalianFestivity;
|
||||
|
||||
statusMessage = $"Saved at {DateTime.Now:t}";
|
||||
Navigation.NavigateTo($"/calendar/{selectedDate:yyyy-MM}");
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (!isExistingUnit || string.IsNullOrWhiteSpace(unitId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var confirmed = await JS.InvokeAsync<bool>("confirm", $"Delete work unit '{label}' on {selectedDate:dddd d MMMM}?\nThis cannot be undone.");
|
||||
if (!confirmed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var deleted = await WorkDayService.DeleteWorkUnitAsync(selectedDate, unitId);
|
||||
if (deleted)
|
||||
{
|
||||
Navigation.NavigateTo($"/calendar/{selectedDate:yyyy-MM}");
|
||||
return;
|
||||
}
|
||||
|
||||
statusMessage = "Unable to delete the work unit.";
|
||||
}
|
||||
|
||||
private void BackToCalendar()
|
||||
{
|
||||
Navigation.NavigateTo($"/calendar/{selectedDate:yyyy-MM}");
|
||||
}
|
||||
|
||||
private void RecomputeDayTotals()
|
||||
{
|
||||
var existingUnits = selectedDay?.WorkUnits ?? [];
|
||||
dayTotalHours = existingUnits
|
||||
.Where(unit => !string.Equals(unit.Id, unitId, StringComparison.Ordinal))
|
||||
.Sum(unit => unit.ManualWorkedHours) + manualWorkedHours;
|
||||
|
||||
dayWorkUnitCount = existingUnits
|
||||
.Where(unit => !string.Equals(unit.Id, unitId, StringComparison.Ordinal))
|
||||
.Count() + 1;
|
||||
}
|
||||
|
||||
private static TimeOnly? ParseTime(string? value)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(value) && TimeOnly.TryParse(value, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
private static decimal? CalculateDuration(TimeOnly? startTime, TimeOnly? endTime)
|
||||
{
|
||||
if (!startTime.HasValue || !endTime.HasValue || endTime <= startTime)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.Round((decimal)(endTime.Value - startTime.Value).TotalHours, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private static bool TryParseDurationHours(string? value, out decimal hours)
|
||||
{
|
||||
hours = 0m;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TimeSpan.TryParseExact(value, [@"h\:mm", @"hh\:mm"], null, out var timeSpan))
|
||||
{
|
||||
hours = Math.Round((decimal)timeSpan.TotalMinutes / 60m, 2, MidpointRounding.AwayFromZero);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (decimal.TryParse(value, out var decimalHours))
|
||||
{
|
||||
hours = Math.Max(0m, decimalHours);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string FormatHours(decimal? value) => value.HasValue ? FormatDurationHours(value.Value) : "—";
|
||||
|
||||
private static string FormatSignedHours(decimal value) => value switch
|
||||
{
|
||||
> 0 => $"+{FormatDurationHours(value)}",
|
||||
< 0 => $"-{FormatDurationHours(Math.Abs(value))}",
|
||||
_ => "00:00"
|
||||
};
|
||||
|
||||
private static string FormatDurationHours(decimal value)
|
||||
{
|
||||
var totalMinutes = (int)Math.Round(value * 60m, MidpointRounding.AwayFromZero);
|
||||
var sign = totalMinutes < 0 ? "-" : string.Empty;
|
||||
totalMinutes = Math.Abs(totalMinutes);
|
||||
var hours = totalMinutes / 60;
|
||||
var minutes = totalMinutes % 60;
|
||||
return $"{sign}{hours:00}:{minutes:00}";
|
||||
}
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
RecomputePreview();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ public sealed class AppSettingsDocument
|
|||
|
||||
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;
|
||||
|
|
|
|||
20
Domain/CalendarEventDocument.cs
Normal file
20
Domain/CalendarEventDocument.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
namespace WorkTracker.Domain;
|
||||
|
||||
public sealed class CalendarEventDocument
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
public CalendarEventType EventType { get; set; } = CalendarEventType.Generic;
|
||||
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
public TimeOnly? StartTime { get; set; }
|
||||
|
||||
public TimeOnly? EndTime { get; set; }
|
||||
|
||||
public decimal? DurationHours { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
10
Domain/CalendarEventType.cs
Normal file
10
Domain/CalendarEventType.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
namespace WorkTracker.Domain;
|
||||
|
||||
public enum CalendarEventType
|
||||
{
|
||||
Generic = 0,
|
||||
DayOff = 1,
|
||||
Closure = 2,
|
||||
Holiday = 3,
|
||||
Illness = 4
|
||||
}
|
||||
|
|
@ -4,8 +4,6 @@ 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;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@ public sealed class MonthlySummaryModel
|
|||
|
||||
public decimal TotalWorkedHours { get; set; }
|
||||
|
||||
public decimal TotalPreviewWorkedHours { get; set; }
|
||||
|
||||
public int CountedWorkUnits { get; set; }
|
||||
|
||||
public int PreviewWorkUnits { get; set; }
|
||||
|
||||
public int OfficeDays { get; set; }
|
||||
|
||||
public int HomeDays { get; set; }
|
||||
|
|
|
|||
26
Domain/MonthlyTimesheetDaySummary.cs
Normal file
26
Domain/MonthlyTimesheetDaySummary.cs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
namespace WorkTracker.Domain;
|
||||
|
||||
public sealed class MonthlyTimesheetDaySummary
|
||||
{
|
||||
public DateOnly Date { get; set; }
|
||||
|
||||
public decimal OfficeHours { get; set; }
|
||||
|
||||
public decimal HomeHours { get; set; }
|
||||
|
||||
public decimal OvertimeHours { get; set; }
|
||||
|
||||
public decimal WeekendHours { get; set; }
|
||||
|
||||
public decimal NightHours { get; set; }
|
||||
|
||||
public decimal VacationDays { get; set; }
|
||||
|
||||
public decimal PermitHours { get; set; }
|
||||
|
||||
public decimal CompensatoryRestDays { get; set; }
|
||||
|
||||
public decimal SickDays { get; set; }
|
||||
|
||||
public decimal HolidayDays { get; set; }
|
||||
}
|
||||
46
Domain/MonthlyTimesheetModel.cs
Normal file
46
Domain/MonthlyTimesheetModel.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
namespace WorkTracker.Domain;
|
||||
|
||||
public sealed class MonthlyTimesheetModel
|
||||
{
|
||||
public int Year { get; set; }
|
||||
|
||||
public int Month { get; set; }
|
||||
|
||||
public List<MonthlyTimesheetDayModel> Days { get; set; } = [];
|
||||
|
||||
public List<MonthlyTimesheetRowModel> Rows { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class MonthlyTimesheetDayModel
|
||||
{
|
||||
public DateOnly Date { get; set; }
|
||||
|
||||
public bool IsWeekend { get; set; }
|
||||
|
||||
public bool IsHoliday { get; set; }
|
||||
|
||||
public bool IsClosure { get; set; }
|
||||
|
||||
public List<string> WorkUnitSummaries { get; set; } = [];
|
||||
|
||||
public List<string> EventSummaries { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class MonthlyTimesheetRowModel
|
||||
{
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
public MonthlyTimesheetValueFormat ValueFormat { get; set; }
|
||||
|
||||
public List<decimal?> DailyValues { get; set; } = [];
|
||||
|
||||
public decimal? Total { get; set; }
|
||||
}
|
||||
|
||||
public enum MonthlyTimesheetValueFormat
|
||||
{
|
||||
Hours = 0,
|
||||
Days = 1
|
||||
}
|
||||
|
|
@ -6,33 +6,13 @@ public sealed class WorkDayDocument
|
|||
|
||||
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 List<WorkUnitDocument> WorkUnits { get; set; } = [];
|
||||
|
||||
public CoeffSnapshotDocument CoeffSnapshot { get; set; } = new();
|
||||
public List<CalendarEventDocument> CalendarEvents { get; set; } = [];
|
||||
|
||||
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
|
|
|
|||
34
Domain/WorkUnitDocument.cs
Normal file
34
Domain/WorkUnitDocument.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
namespace WorkTracker.Domain;
|
||||
|
||||
public sealed class WorkUnitDocument
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
public string Label { get; set; } = "Work unit";
|
||||
|
||||
public WorkUnitLocation Location { get; set; } = WorkUnitLocation.Office;
|
||||
|
||||
public TimeOnly? StartTime { get; set; }
|
||||
|
||||
public TimeOnly? EndTime { get; set; }
|
||||
|
||||
public bool IsPreview { get; set; }
|
||||
|
||||
public decimal ManualWorkedHours { get; set; }
|
||||
|
||||
public decimal CalculatedWorkedHours { get; set; }
|
||||
|
||||
public decimal WorkedHoursDelta { get; set; }
|
||||
|
||||
public decimal GrossIncome { get; set; }
|
||||
|
||||
public decimal NetIncome { 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;
|
||||
}
|
||||
7
Domain/WorkUnitLocation.cs
Normal file
7
Domain/WorkUnitLocation.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace WorkTracker.Domain;
|
||||
|
||||
public enum WorkUnitLocation
|
||||
{
|
||||
Office = 1,
|
||||
Home = 2
|
||||
}
|
||||
|
|
@ -8,6 +8,51 @@ Quick run (Docker Engine required):
|
|||
|
||||
2. App will be available on host port 8002 -> container 8080 (http://localhost:8002).
|
||||
|
||||
Production deployment:
|
||||
|
||||
- Put deployment values in a `.env` file next to `docker-compose.yml` or export them in the shell before running Docker Compose.
|
||||
- The base compose file is now parameterized for host port, persisted storage path, image tag, auth mode, seeded user credentials, allowed hosts, and healthcheck timings.
|
||||
- For a direct production deployment, define at least `WORKTRACKER_DATA_PATH`. If you enable the built-in login flow, also define `APPAUTH_ENABLED=true` and a strong `SINGLEUSER_PASSWORD` before first startup.
|
||||
|
||||
Deployment variables:
|
||||
|
||||
| Variable | Required for production | Default | Purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| `WORKTRACKER_DATA_PATH` | Yes | `./.docker-data/couchbase` | Host path mounted to `/data/couchbase` so the embedded Couchbase Lite database survives container replacement. |
|
||||
| `WORKTRACKER_PORT` | Usually | `8002` | Host port published for the app container. |
|
||||
| `IMAGE_REGISTRY` | No | `worktracker` | Image repository/name used by the `worktracker` service, for example `ghcr.io/your-org/worktracker`. |
|
||||
| `IMAGE_TAG` | Usually | `latest` | Image tag to deploy. Pin this to a release tag instead of using `latest`. |
|
||||
| `ASPNETCORE_ENVIRONMENT` | No | `Production` | ASP.NET Core environment name. Keep this as `Production` for real deployments. |
|
||||
| `ASPNETCORE_FORWARDEDHEADERS_ENABLED` | Recommended | `true` | Enables forwarded header handling when the app runs behind a reverse proxy or Zero Trust tunnel. |
|
||||
| `ALLOWED_HOSTS` | Recommended | `*` | ASP.NET Core allowed hostnames. Set this to your public hostname instead of leaving it wide open. |
|
||||
| `USE_HTTPS_REDIRECTION` | Depends | `false` | Enables ASP.NET Core HTTPS redirection. Leave this `false` when TLS terminates upstream unless you have verified forwarded headers and redirect behavior. |
|
||||
| `COUCHBASELITE_DATABASE_NAME` | No | `worktracker` | Embedded Couchbase Lite database name. |
|
||||
| `APPAUTH_ENABLED` | Yes, choose a mode explicitly | `false` | Enables the built-in login flow when `true`. Leave `false` only if access is protected upstream and you want zero-trust style default-admin passthrough. |
|
||||
| `APPAUTH_DEFAULT_USERNAME` | When `APPAUTH_ENABLED=false` | `Admin` | Display name injected for every request while built-in auth is disabled. |
|
||||
| `APPAUTH_DEFAULT_USERID` | When `APPAUTH_ENABLED=false` | `ADMIN` | User identifier injected for every request while built-in auth is disabled. |
|
||||
| `SINGLEUSER_SEED_ON_STARTUP` | No | `true` | Seeds the built-in admin account into Couchbase Lite on first startup if it does not exist yet. |
|
||||
| `SINGLEUSER_USERNAME` | When `APPAUTH_ENABLED=true` | `Admin` | Username for the seeded built-in account. |
|
||||
| `SINGLEUSER_PASSWORD` | When `APPAUTH_ENABLED=true` and the database is new | `Disagio` | Initial password for the seeded built-in account. Set this to a strong secret before the first production start. |
|
||||
| `WORKTRACKER_HEALTHCHECK_INTERVAL` | No | `30s` | Docker healthcheck interval. |
|
||||
| `WORKTRACKER_HEALTHCHECK_TIMEOUT` | No | `5s` | Docker healthcheck timeout. |
|
||||
| `WORKTRACKER_HEALTHCHECK_START_PERIOD` | No | `10s` | Grace period before Docker starts evaluating health. |
|
||||
| `WORKTRACKER_HEALTHCHECK_RETRIES` | No | `3` | Consecutive healthcheck failures before the container is marked unhealthy. |
|
||||
|
||||
Example production `.env`:
|
||||
|
||||
```dotenv
|
||||
WORKTRACKER_DATA_PATH=/srv/worktracker/couchbase
|
||||
WORKTRACKER_PORT=8002
|
||||
IMAGE_REGISTRY=ghcr.io/your-org/worktracker
|
||||
IMAGE_TAG=2026.04.20
|
||||
ALLOWED_HOSTS=worktracker.example.com
|
||||
ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
|
||||
USE_HTTPS_REDIRECTION=false
|
||||
APPAUTH_ENABLED=true
|
||||
SINGLEUSER_USERNAME=admin
|
||||
SINGLEUSER_PASSWORD=replace-with-a-strong-secret
|
||||
```
|
||||
|
||||
Authentication mode:
|
||||
|
||||
- Authentication is disabled by default and every request runs as the configured default admin user. This is intended for deployments fronted by Cloudflare Zero Trust.
|
||||
|
|
@ -39,7 +84,7 @@ Debugging in Docker from VS Code:
|
|||
|
||||
- Use the `WorkTracker: Debug in Docker` launch configuration.
|
||||
- VS Code brings up the development container with `docker compose`, builds the app in `Debug`, and launches `WorkTracker.dll` under `vsdbg` inside the container.
|
||||
- When the app reports that it is listening, VS Code automatically opens Microsoft Edge in browser debug mode against http://localhost:8002.
|
||||
- VS Code waits for the app to report that it is listening before opening Microsoft Edge in browser debug mode against http://localhost:8002.
|
||||
- The app remains available at http://localhost:8002 while the debugger is attached.
|
||||
- Stopping the debug session runs `docker compose down` for the debug stack.
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ public sealed class CouchbaseLiteAppSettingsService : IAppSettingsService
|
|||
{
|
||||
var document = new MutableDocument(DefaultSettingsId);
|
||||
document.SetDouble("standardWorkHoursPerDay", Decimal.ToDouble(settings.StandardWorkHoursPerDay));
|
||||
document.SetDouble("lunchBreakHours", Decimal.ToDouble(settings.LunchBreakHours));
|
||||
document.SetDouble("hourlyGrossRate", Decimal.ToDouble(settings.HourlyGrossRate));
|
||||
document.SetDouble("profitabilityCoefficient", Decimal.ToDouble(settings.ProfitabilityCoefficient));
|
||||
document.SetDouble("inpsRate", Decimal.ToDouble(settings.InpsRate));
|
||||
|
|
@ -67,7 +66,6 @@ public sealed class CouchbaseLiteAppSettingsService : IAppSettingsService
|
|||
{
|
||||
Id = document.Id,
|
||||
StandardWorkHoursPerDay = ReadDecimal(document, "standardWorkHoursPerDay", 8m),
|
||||
LunchBreakHours = ReadDecimal(document, "lunchBreakHours", 1m),
|
||||
HourlyGrossRate = ReadDecimal(document, "hourlyGrossRate", 17.5m),
|
||||
ProfitabilityCoefficient = ReadDecimal(document, "profitabilityCoefficient", 0.67m),
|
||||
InpsRate = ReadDecimal(document, "inpsRate", 0.2607m),
|
||||
|
|
|
|||
|
|
@ -31,45 +31,132 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
|||
return Task.FromResult(doc is not null ? Map(doc) : null);
|
||||
}
|
||||
|
||||
public async Task<WorkDayDocument> SaveAsync(WorkDayDocument workDay, CancellationToken cancellationToken = default)
|
||||
public async Task<WorkUnitDocument?> GetWorkUnitAsync(DateOnly date, string workUnitId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var day = await GetAsync(date, cancellationToken);
|
||||
return day?.WorkUnits.FirstOrDefault(unit => string.Equals(unit.Id, workUnitId, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
public async Task<CalendarEventDocument?> GetCalendarEventAsync(DateOnly date, string calendarEventId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var day = await GetAsync(date, cancellationToken);
|
||||
return day?.CalendarEvents.FirstOrDefault(calendarEvent => string.Equals(calendarEvent.Id, calendarEventId, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
public async Task<WorkUnitDocument> SaveWorkUnitAsync(DateOnly date, WorkUnitDocument workUnit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var settings = await appSettingsService.GetAsync(cancellationToken);
|
||||
var festivities = festivitySource.GetFestivities(workDay.Date.Year);
|
||||
var day = await GetOrCreateDayAsync(date, cancellationToken);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var existingIndex = day.WorkUnits.FindIndex(unit => string.Equals(unit.Id, workUnit.Id, StringComparison.Ordinal));
|
||||
var existingCreatedAt = existingIndex >= 0 ? day.WorkUnits[existingIndex].CreatedAtUtc : now;
|
||||
|
||||
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
|
||||
workUnit.Id = string.IsNullOrWhiteSpace(workUnit.Id) ? Guid.NewGuid().ToString("N") : workUnit.Id;
|
||||
workUnit.Label = string.IsNullOrWhiteSpace(workUnit.Label) ? "Work unit" : workUnit.Label.Trim();
|
||||
workUnit.ManualWorkedHours = Math.Max(0m, workUnit.ManualWorkedHours);
|
||||
workUnit.CoeffSnapshot = new CoeffSnapshotDocument
|
||||
{
|
||||
StandardWorkHoursPerDay = settings.StandardWorkHoursPerDay,
|
||||
LunchBreakHours = settings.LunchBreakHours,
|
||||
HourlyGrossRate = settings.HourlyGrossRate,
|
||||
ProfitabilityCoefficient = settings.ProfitabilityCoefficient,
|
||||
InpsRate = settings.InpsRate,
|
||||
SubstituteTaxRate = settings.SubstituteTaxRate
|
||||
};
|
||||
workUnit.CreatedAtUtc = existingCreatedAt;
|
||||
workUnit.UpdatedAtUtc = now;
|
||||
|
||||
Compute(workDay);
|
||||
Compute(workUnit);
|
||||
|
||||
// Preserve creation timestamp for existing documents
|
||||
var existing = workDaysCollection.GetDocument(workDay.Id);
|
||||
if (existing is not null)
|
||||
if (existingIndex >= 0)
|
||||
{
|
||||
workDay.CreatedAtUtc = ReadDateTimeOffset(existing, "createdAtUtc");
|
||||
day.WorkUnits[existingIndex] = workUnit;
|
||||
}
|
||||
else
|
||||
{
|
||||
workDay.CreatedAtUtc = DateTimeOffset.UtcNow;
|
||||
day.WorkUnits.Add(workUnit);
|
||||
}
|
||||
|
||||
workDay.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
day.UpdatedAtUtc = now;
|
||||
SortEntries(day);
|
||||
SaveDocument(day);
|
||||
return workUnit;
|
||||
}
|
||||
|
||||
SaveDocument(workDay);
|
||||
return workDay;
|
||||
public async Task<CalendarEventDocument> SaveCalendarEventAsync(DateOnly date, CalendarEventDocument calendarEvent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var day = await GetOrCreateDayAsync(date, cancellationToken);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var existingIndex = day.CalendarEvents.FindIndex(entry => string.Equals(entry.Id, calendarEvent.Id, StringComparison.Ordinal));
|
||||
var existingCreatedAt = existingIndex >= 0 ? day.CalendarEvents[existingIndex].CreatedAtUtc : now;
|
||||
|
||||
calendarEvent.Id = string.IsNullOrWhiteSpace(calendarEvent.Id) ? Guid.NewGuid().ToString("N") : calendarEvent.Id;
|
||||
calendarEvent.Description = string.IsNullOrWhiteSpace(calendarEvent.Description)
|
||||
? "Calendar entry"
|
||||
: calendarEvent.Description.Trim();
|
||||
calendarEvent.CreatedAtUtc = existingCreatedAt;
|
||||
calendarEvent.UpdatedAtUtc = now;
|
||||
|
||||
Compute(calendarEvent);
|
||||
|
||||
if (existingIndex >= 0)
|
||||
{
|
||||
day.CalendarEvents[existingIndex] = calendarEvent;
|
||||
}
|
||||
else
|
||||
{
|
||||
day.CalendarEvents.Add(calendarEvent);
|
||||
}
|
||||
|
||||
day.UpdatedAtUtc = now;
|
||||
SortEntries(day);
|
||||
SaveDocument(day);
|
||||
return calendarEvent;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteWorkUnitAsync(DateOnly date, string workUnitId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var day = await GetAsync(date, cancellationToken);
|
||||
if (day is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var removed = day.WorkUnits.RemoveAll(unit => string.Equals(unit.Id, workUnitId, StringComparison.Ordinal));
|
||||
if (removed == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return DeleteOrSaveDay(day);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteCalendarEventAsync(DateOnly date, string calendarEventId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var day = await GetAsync(date, cancellationToken);
|
||||
if (day is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var removed = day.CalendarEvents.RemoveAll(calendarEvent => string.Equals(calendarEvent.Id, calendarEventId, StringComparison.Ordinal));
|
||||
if (removed == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return DeleteOrSaveDay(day);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<WorkDayDocument>> GetRangeAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
|
||||
|
|
@ -90,88 +177,180 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
|||
return Task.FromResult<IReadOnlyList<WorkDayDocument>>(results);
|
||||
}
|
||||
|
||||
public async Task<MonthlySummaryModel> GetMonthlySummaryAsync(int year, int month, CancellationToken cancellationToken = default)
|
||||
public async Task<MonthlySummaryModel> GetMonthlySummaryAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var from = new DateOnly(year, month, 1);
|
||||
var to = from.AddMonths(1).AddDays(-1);
|
||||
var days = await GetRangeAsync(from, to, cancellationToken);
|
||||
|
||||
var includedUnits = days
|
||||
.SelectMany(day => day.WorkUnits.Where(unit => includePreview || !unit.IsPreview).Select(unit => new { day.Date, Unit = unit }))
|
||||
.ToList();
|
||||
|
||||
var previewUnits = days
|
||||
.SelectMany(day => day.WorkUnits.Where(unit => unit.IsPreview).Select(unit => new { day.Date, Unit = unit }))
|
||||
.ToList();
|
||||
|
||||
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)
|
||||
TotalWorkedHours = includedUnits.Sum(item => item.Unit.ManualWorkedHours),
|
||||
TotalPreviewWorkedHours = previewUnits.Sum(item => item.Unit.ManualWorkedHours),
|
||||
CountedWorkUnits = includedUnits.Count,
|
||||
PreviewWorkUnits = previewUnits.Count,
|
||||
OfficeDays = includedUnits.Where(item => item.Unit.Location == WorkUnitLocation.Office).Select(item => item.Date).Distinct().Count(),
|
||||
HomeDays = includedUnits.Where(item => item.Unit.Location == WorkUnitLocation.Home).Select(item => item.Date).Distinct().Count(),
|
||||
HolidayDays = CountDaysWithEvent(days, CalendarEventType.Holiday),
|
||||
SickDays = CountDaysWithEvent(days, CalendarEventType.Illness),
|
||||
DaysOff = CountDaysWithEvent(days, CalendarEventType.DayOff),
|
||||
ClosureDays = CountDaysWithEvent(days, CalendarEventType.Closure),
|
||||
TotalHoursOff = days.Sum(day => GetHoursOff(day, includePreview)),
|
||||
TotalGrossIncome = includedUnits.Sum(item => item.Unit.GrossIncome),
|
||||
TotalNetIncome = includedUnits.Sum(item => item.Unit.NetIncome),
|
||||
TotalWorkingDays = includedUnits.Select(item => item.Date).Distinct().Count()
|
||||
};
|
||||
}
|
||||
|
||||
private static void Compute(WorkDayDocument day)
|
||||
public async Task<MonthlyTimesheetModel> GetMonthlyTimesheetAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var coeff = day.CoeffSnapshot;
|
||||
var from = new DateOnly(year, month, 1);
|
||||
var to = from.AddMonths(1).AddDays(-1);
|
||||
var days = await GetRangeAsync(from, to, cancellationToken);
|
||||
var dayLookup = days.ToDictionary(day => day.Date);
|
||||
var settings = await appSettingsService.GetAsync(cancellationToken);
|
||||
|
||||
// Calculate projected exit time
|
||||
if (day.StartTime.HasValue && day.DayType is DayType.Work or DayType.Home)
|
||||
var daySummaries = new List<MonthlyTimesheetDaySummary>();
|
||||
for (var date = from; date <= to; date = date.AddDays(1))
|
||||
{
|
||||
var totalHours = coeff.StandardWorkHoursPerDay + coeff.LunchBreakHours;
|
||||
day.ProjectedExitTime = day.StartTime.Value.Add(TimeSpan.FromHours((double)totalHours));
|
||||
}
|
||||
else
|
||||
{
|
||||
day.ProjectedExitTime = null;
|
||||
dayLookup.TryGetValue(date, out var day);
|
||||
daySummaries.Add(CreateTimesheetDaySummary(day, date, includePreview, settings.StandardWorkHoursPerDay));
|
||||
}
|
||||
|
||||
// Calculate worked hours
|
||||
day.WorkedHoursBase = day.DayType is DayType.Work or DayType.Home
|
||||
? coeff.StandardWorkHoursPerDay
|
||||
: 0m;
|
||||
return new MonthlyTimesheetModel
|
||||
{
|
||||
Year = year,
|
||||
Month = month,
|
||||
Days = daySummaries.Select(summary => new MonthlyTimesheetDayModel
|
||||
{
|
||||
Date = summary.Date,
|
||||
IsWeekend = summary.Date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday,
|
||||
IsHoliday = summary.HolidayDays > 0m || dayLookup.GetValueOrDefault(summary.Date)?.IsItalianFestivity == true,
|
||||
IsClosure = summary.VacationDays > 0m && HasEventType(dayLookup.GetValueOrDefault(summary.Date), CalendarEventType.Closure),
|
||||
WorkUnitSummaries = dayLookup.GetValueOrDefault(summary.Date)?.WorkUnits
|
||||
.Where(unit => includePreview || !unit.IsPreview)
|
||||
.Select(FormatTimesheetWorkUnitSummary)
|
||||
.ToList() ?? [],
|
||||
EventSummaries = dayLookup.GetValueOrDefault(summary.Date)?.CalendarEvents
|
||||
.Select(FormatTimesheetEventSummary)
|
||||
.ToList() ?? []
|
||||
}).ToList(),
|
||||
Rows =
|
||||
[
|
||||
CreateTimesheetRow("office", "Ore lavorative in presenza", MonthlyTimesheetValueFormat.Hours, daySummaries.Select(summary => summary.OfficeHours)),
|
||||
CreateTimesheetRow("home", "Ore lavorative in smart working", MonthlyTimesheetValueFormat.Hours, daySummaries.Select(summary => summary.HomeHours)),
|
||||
CreateTimesheetRow("overtime", "Straordinari", MonthlyTimesheetValueFormat.Hours, daySummaries.Select(summary => summary.OvertimeHours)),
|
||||
CreateTimesheetRow("weekend", "Weekend", MonthlyTimesheetValueFormat.Hours, daySummaries.Select(summary => summary.WeekendHours)),
|
||||
CreateTimesheetRow("night", "Notturni (22-06)", MonthlyTimesheetValueFormat.Hours, daySummaries.Select(summary => summary.NightHours)),
|
||||
CreateTimesheetRow("vacation", "Giorni di ferie", MonthlyTimesheetValueFormat.Days, daySummaries.Select(summary => summary.VacationDays)),
|
||||
CreateTimesheetRow("permit", "Ore di permesso", MonthlyTimesheetValueFormat.Hours, daySummaries.Select(summary => summary.PermitHours)),
|
||||
CreateTimesheetRow("compensatory-rest", "Riposo compensativo", MonthlyTimesheetValueFormat.Days, daySummaries.Select(summary => summary.CompensatoryRestDays), includeZeroTotal: false),
|
||||
CreateTimesheetRow("sick", "Giorni di malattia", MonthlyTimesheetValueFormat.Days, daySummaries.Select(summary => summary.SickDays)),
|
||||
CreateTimesheetRow("holiday", "Festività", MonthlyTimesheetValueFormat.Days, daySummaries.Select(summary => summary.HolidayDays))
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
day.WorkedHoursFinal = day.WorkedHoursBase + day.ExtraHoursDelta;
|
||||
public async Task<int> GenerateMonthlyPreviewWorkUnitsAsync(int year, int month, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// 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;
|
||||
var settings = await appSettingsService.GetAsync(cancellationToken);
|
||||
var festivities = festivitySource.GetFestivities(year);
|
||||
var from = new DateOnly(year, month, 1);
|
||||
var to = from.AddMonths(1).AddDays(-1);
|
||||
var createdDays = 0;
|
||||
|
||||
// Income calculations
|
||||
day.GrossIncome = day.WorkedHoursFinal * coeff.HourlyGrossRate;
|
||||
var taxableBase = day.GrossIncome * coeff.ProfitabilityCoefficient;
|
||||
day.NetIncome = day.GrossIncome - (taxableBase * coeff.InpsRate) - (taxableBase * coeff.SubstituteTaxRate);
|
||||
for (var date = from; date <= to; date = date.AddDays(1))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday || festivities.Contains(date))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var day = await GetOrCreateDayAsync(date, cancellationToken);
|
||||
if (day.WorkUnits.Count > 0 || day.CalendarEvents.Any(entry => IsNonWorkingEvent(entry.EventType)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
day.WorkUnits.Add(CreatePreviewWorkUnit("Morning", new TimeOnly(8, 30), new TimeOnly(13, 0), settings));
|
||||
day.WorkUnits.Add(CreatePreviewWorkUnit("Afternoon", new TimeOnly(14, 0), new TimeOnly(17, 30), settings));
|
||||
day.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
|
||||
SortEntries(day);
|
||||
SaveDocument(day);
|
||||
createdDays++;
|
||||
}
|
||||
|
||||
return createdDays;
|
||||
}
|
||||
|
||||
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));
|
||||
var workUnits = new MutableArrayObject();
|
||||
foreach (var unit in day.WorkUnits)
|
||||
{
|
||||
var entry = new MutableDictionaryObject();
|
||||
entry.SetString("id", unit.Id);
|
||||
entry.SetString("label", unit.Label);
|
||||
entry.SetInt("location", (int)unit.Location);
|
||||
entry.SetString("startTime", unit.StartTime?.ToString("HH:mm"));
|
||||
entry.SetString("endTime", unit.EndTime?.ToString("HH:mm"));
|
||||
entry.SetBoolean("isPreview", unit.IsPreview);
|
||||
entry.SetDouble("manualWorkedHours", decimal.ToDouble(unit.ManualWorkedHours));
|
||||
entry.SetDouble("calculatedWorkedHours", decimal.ToDouble(unit.CalculatedWorkedHours));
|
||||
entry.SetDouble("workedHoursDelta", decimal.ToDouble(unit.WorkedHoursDelta));
|
||||
entry.SetDouble("grossIncome", decimal.ToDouble(unit.GrossIncome));
|
||||
entry.SetDouble("netIncome", decimal.ToDouble(unit.NetIncome));
|
||||
entry.SetString("notes", unit.Notes);
|
||||
entry.SetDouble("coeff_standardWorkHoursPerDay", decimal.ToDouble(unit.CoeffSnapshot.StandardWorkHoursPerDay));
|
||||
entry.SetDouble("coeff_hourlyGrossRate", decimal.ToDouble(unit.CoeffSnapshot.HourlyGrossRate));
|
||||
entry.SetDouble("coeff_profitabilityCoefficient", decimal.ToDouble(unit.CoeffSnapshot.ProfitabilityCoefficient));
|
||||
entry.SetDouble("coeff_inpsRate", decimal.ToDouble(unit.CoeffSnapshot.InpsRate));
|
||||
entry.SetDouble("coeff_substituteTaxRate", decimal.ToDouble(unit.CoeffSnapshot.SubstituteTaxRate));
|
||||
entry.SetString("createdAtUtc", unit.CreatedAtUtc.ToString("O"));
|
||||
entry.SetString("updatedAtUtc", unit.UpdatedAtUtc.ToString("O"));
|
||||
workUnits.AddDictionary(entry);
|
||||
}
|
||||
|
||||
var calendarEvents = new MutableArrayObject();
|
||||
foreach (var calendarEvent in day.CalendarEvents)
|
||||
{
|
||||
var entry = new MutableDictionaryObject();
|
||||
entry.SetString("id", calendarEvent.Id);
|
||||
entry.SetInt("eventType", (int)calendarEvent.EventType);
|
||||
entry.SetString("description", calendarEvent.Description);
|
||||
entry.SetString("startTime", calendarEvent.StartTime?.ToString("HH:mm"));
|
||||
entry.SetString("endTime", calendarEvent.EndTime?.ToString("HH:mm"));
|
||||
if (calendarEvent.DurationHours.HasValue)
|
||||
{
|
||||
entry.SetDouble("durationHours", decimal.ToDouble(calendarEvent.DurationHours.Value));
|
||||
}
|
||||
entry.SetString("createdAtUtc", calendarEvent.CreatedAtUtc.ToString("O"));
|
||||
entry.SetString("updatedAtUtc", calendarEvent.UpdatedAtUtc.ToString("O"));
|
||||
calendarEvents.AddDictionary(entry);
|
||||
}
|
||||
|
||||
doc.SetArray("workUnits", workUnits);
|
||||
doc.SetArray("calendarEvents", calendarEvents);
|
||||
|
||||
doc.SetString("createdAtUtc", day.CreatedAtUtc.ToString("O"));
|
||||
doc.SetString("updatedAtUtc", day.UpdatedAtUtc.ToString("O"));
|
||||
|
|
@ -179,39 +358,426 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
|||
workDaysCollection.Save(doc);
|
||||
}
|
||||
|
||||
private bool DeleteOrSaveDay(WorkDayDocument day)
|
||||
{
|
||||
if (day.WorkUnits.Count == 0 && day.CalendarEvents.Count == 0)
|
||||
{
|
||||
var existing = workDaysCollection.GetDocument(day.Id);
|
||||
if (existing is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
workDaysCollection.Delete(existing);
|
||||
return true;
|
||||
}
|
||||
|
||||
day.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
SortEntries(day);
|
||||
SaveDocument(day);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static WorkDayDocument Map(Document doc)
|
||||
{
|
||||
if (!doc.Contains("workUnits") && !doc.Contains("calendarEvents"))
|
||||
{
|
||||
return MapLegacy(doc);
|
||||
}
|
||||
|
||||
var workUnits = new List<WorkUnitDocument>();
|
||||
var workUnitsArray = doc.GetArray("workUnits");
|
||||
if (workUnitsArray is not null)
|
||||
{
|
||||
for (var i = 0; i < workUnitsArray.Count; i++)
|
||||
{
|
||||
var unit = workUnitsArray.GetDictionary(i);
|
||||
if (unit is not null)
|
||||
{
|
||||
workUnits.Add(MapWorkUnit(unit));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var calendarEvents = new List<CalendarEventDocument>();
|
||||
var calendarEventsArray = doc.GetArray("calendarEvents");
|
||||
if (calendarEventsArray is not null)
|
||||
{
|
||||
for (var i = 0; i < calendarEventsArray.Count; i++)
|
||||
{
|
||||
var calendarEvent = calendarEventsArray.GetDictionary(i);
|
||||
if (calendarEvent is not null)
|
||||
{
|
||||
calendarEvents.Add(MapCalendarEvent(calendarEvent));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
},
|
||||
WorkUnits = workUnits,
|
||||
CalendarEvents = calendarEvents,
|
||||
CreatedAtUtc = ReadDateTimeOffset(doc, "createdAtUtc"),
|
||||
UpdatedAtUtc = ReadDateTimeOffset(doc, "updatedAtUtc")
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<WorkDayDocument> GetOrCreateDayAsync(DateOnly date, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await GetAsync(date, cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.IsWeekend = date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
|
||||
existing.IsItalianFestivity = festivitySource.GetFestivities(date.Year).Contains(date);
|
||||
existing.Id = date.ToString("yyyy-MM-dd");
|
||||
existing.Date = date;
|
||||
return existing;
|
||||
}
|
||||
|
||||
return new WorkDayDocument
|
||||
{
|
||||
Id = date.ToString("yyyy-MM-dd"),
|
||||
Date = date,
|
||||
IsWeekend = date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday,
|
||||
IsItalianFestivity = festivitySource.GetFestivities(date.Year).Contains(date),
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||
UpdatedAtUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static void Compute(WorkUnitDocument unit)
|
||||
{
|
||||
unit.CalculatedWorkedHours = CalculateDuration(unit.StartTime, unit.EndTime) ?? 0m;
|
||||
unit.WorkedHoursDelta = unit.ManualWorkedHours - unit.CalculatedWorkedHours;
|
||||
|
||||
var coeff = unit.CoeffSnapshot;
|
||||
unit.GrossIncome = unit.ManualWorkedHours * coeff.HourlyGrossRate;
|
||||
var taxableBase = unit.GrossIncome * coeff.ProfitabilityCoefficient;
|
||||
unit.NetIncome = unit.GrossIncome - (taxableBase * coeff.InpsRate) - (taxableBase * coeff.SubstituteTaxRate);
|
||||
}
|
||||
|
||||
private static void Compute(CalendarEventDocument calendarEvent)
|
||||
{
|
||||
calendarEvent.DurationHours = CalculateDuration(calendarEvent.StartTime, calendarEvent.EndTime);
|
||||
}
|
||||
|
||||
private static decimal? CalculateDuration(TimeOnly? startTime, TimeOnly? endTime)
|
||||
{
|
||||
if (!startTime.HasValue || !endTime.HasValue || endTime <= startTime)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.Round((decimal)(endTime.Value - startTime.Value).TotalHours, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private static int CountDaysWithEvent(IEnumerable<WorkDayDocument> days, CalendarEventType eventType)
|
||||
{
|
||||
return days.Count(day => day.CalendarEvents.Any(calendarEvent => calendarEvent.EventType == eventType));
|
||||
}
|
||||
|
||||
private static MonthlyTimesheetDaySummary CreateTimesheetDaySummary(WorkDayDocument? day, DateOnly date, bool includePreview, decimal defaultStandardHours)
|
||||
{
|
||||
var includedUnits = day?.WorkUnits.Where(unit => includePreview || !unit.IsPreview).ToList() ?? [];
|
||||
var totalHours = includedUnits.Sum(unit => unit.ManualWorkedHours);
|
||||
var explicitHoliday = HasEventType(day, CalendarEventType.Holiday);
|
||||
var illness = HasEventType(day, CalendarEventType.Illness);
|
||||
var dayOff = HasEventType(day, CalendarEventType.DayOff);
|
||||
var closure = HasEventType(day, CalendarEventType.Closure);
|
||||
var isWeekend = date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
|
||||
var isAutomaticHoliday = day?.IsItalianFestivity ?? false;
|
||||
var standardHours = includedUnits.FirstOrDefault()?.CoeffSnapshot.StandardWorkHoursPerDay ?? defaultStandardHours;
|
||||
var nightHours = includedUnits.Sum(GetNightHours);
|
||||
var weekdayDaytimeHours = isWeekend ? 0m : Math.Max(0m, totalHours - nightHours);
|
||||
var suppressVacation = isWeekend || explicitHoliday || isAutomaticHoliday || illness;
|
||||
var hasNonWorkingEvent = explicitHoliday || illness || dayOff || closure;
|
||||
var permitHours = !isWeekend && !isAutomaticHoliday && !hasNonWorkingEvent && totalHours < standardHours
|
||||
? standardHours - totalHours
|
||||
: 0m;
|
||||
|
||||
return new MonthlyTimesheetDaySummary
|
||||
{
|
||||
Date = date,
|
||||
OfficeHours = includedUnits.Where(unit => unit.Location == WorkUnitLocation.Office).Sum(unit => unit.ManualWorkedHours),
|
||||
HomeHours = includedUnits.Where(unit => unit.Location == WorkUnitLocation.Home).Sum(unit => unit.ManualWorkedHours),
|
||||
OvertimeHours = Math.Max(0m, weekdayDaytimeHours - standardHours),
|
||||
WeekendHours = isWeekend ? totalHours : 0m,
|
||||
NightHours = nightHours,
|
||||
VacationDays = (dayOff || closure) && !suppressVacation ? 1m : 0m,
|
||||
PermitHours = Math.Max(0m, permitHours),
|
||||
CompensatoryRestDays = 0m,
|
||||
SickDays = illness ? 1m : 0m,
|
||||
HolidayDays = explicitHoliday && !isWeekend ? 1m : 0m
|
||||
};
|
||||
}
|
||||
|
||||
private static MonthlyTimesheetRowModel CreateTimesheetRow(
|
||||
string key,
|
||||
string label,
|
||||
MonthlyTimesheetValueFormat valueFormat,
|
||||
IEnumerable<decimal> values,
|
||||
bool includeZeroTotal = true)
|
||||
{
|
||||
var dailyValues = values
|
||||
.Select(value => value > 0m ? value : (decimal?)null)
|
||||
.ToList();
|
||||
|
||||
var total = dailyValues.Where(value => value.HasValue).Sum(value => value ?? 0m);
|
||||
|
||||
return new MonthlyTimesheetRowModel
|
||||
{
|
||||
Key = key,
|
||||
Label = label,
|
||||
ValueFormat = valueFormat,
|
||||
DailyValues = dailyValues,
|
||||
Total = includeZeroTotal || total > 0m ? total : null
|
||||
};
|
||||
}
|
||||
|
||||
private static decimal GetHoursOff(WorkDayDocument day, bool includePreview)
|
||||
{
|
||||
var includedUnits = day.WorkUnits.Where(unit => includePreview || !unit.IsPreview).ToList();
|
||||
if (includedUnits.Count == 0)
|
||||
{
|
||||
return 0m;
|
||||
}
|
||||
|
||||
var standardHours = includedUnits[0].CoeffSnapshot.StandardWorkHoursPerDay;
|
||||
var countedHours = includedUnits.Sum(unit => unit.ManualWorkedHours);
|
||||
return Math.Max(0m, standardHours - countedHours);
|
||||
}
|
||||
|
||||
private static bool IsNonWorkingEvent(CalendarEventType eventType)
|
||||
{
|
||||
return eventType is CalendarEventType.DayOff or CalendarEventType.Closure or CalendarEventType.Holiday or CalendarEventType.Illness;
|
||||
}
|
||||
|
||||
private static bool HasEventType(WorkDayDocument? day, CalendarEventType eventType)
|
||||
{
|
||||
return day?.CalendarEvents.Any(calendarEvent => calendarEvent.EventType == eventType) == true;
|
||||
}
|
||||
|
||||
private static string FormatTimesheetWorkUnitSummary(WorkUnitDocument unit)
|
||||
{
|
||||
var prefix = unit.Location == WorkUnitLocation.Home ? "SW" : "Pres";
|
||||
var hours = FormatCompactHours(unit.ManualWorkedHours);
|
||||
if (unit.StartTime.HasValue && unit.EndTime.HasValue)
|
||||
{
|
||||
return $"{prefix}: {unit.Label} ({unit.StartTime:HH:mm}-{unit.EndTime:HH:mm}, {hours}h{(unit.IsPreview ? ", preview" : string.Empty)})";
|
||||
}
|
||||
|
||||
return $"{prefix}: {unit.Label} ({hours}h{(unit.IsPreview ? ", preview" : string.Empty)})";
|
||||
}
|
||||
|
||||
private static string FormatTimesheetEventSummary(CalendarEventDocument calendarEvent)
|
||||
{
|
||||
if (calendarEvent.StartTime.HasValue)
|
||||
{
|
||||
return $"{calendarEvent.EventType}: {calendarEvent.Description} ({calendarEvent.StartTime:HH:mm})";
|
||||
}
|
||||
|
||||
return $"{calendarEvent.EventType}: {calendarEvent.Description}";
|
||||
}
|
||||
|
||||
private static string FormatCompactHours(decimal value)
|
||||
{
|
||||
return value == decimal.Truncate(value)
|
||||
? value.ToString("0")
|
||||
: value.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static decimal GetNightHours(WorkUnitDocument unit)
|
||||
{
|
||||
if (!unit.StartTime.HasValue || !unit.EndTime.HasValue || unit.EndTime <= unit.StartTime)
|
||||
{
|
||||
return 0m;
|
||||
}
|
||||
|
||||
return GetOverlapHours(unit.StartTime.Value, unit.EndTime.Value, new TimeOnly(0, 0), new TimeOnly(6, 0))
|
||||
+ GetOverlapHours(unit.StartTime.Value, unit.EndTime.Value, new TimeOnly(22, 0), new TimeOnly(23, 59, 59));
|
||||
}
|
||||
|
||||
private static decimal GetOverlapHours(TimeOnly rangeStart, TimeOnly rangeEnd, TimeOnly windowStart, TimeOnly windowEnd)
|
||||
{
|
||||
var overlapStart = rangeStart > windowStart ? rangeStart : windowStart;
|
||||
var overlapEnd = rangeEnd < windowEnd ? rangeEnd : windowEnd;
|
||||
if (overlapEnd <= overlapStart)
|
||||
{
|
||||
return 0m;
|
||||
}
|
||||
|
||||
return Math.Round((decimal)(overlapEnd - overlapStart).TotalHours, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private static WorkUnitDocument CreatePreviewWorkUnit(string label, TimeOnly startTime, TimeOnly endTime, AppSettingsDocument settings)
|
||||
{
|
||||
var workUnit = new WorkUnitDocument
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Label = label,
|
||||
Location = WorkUnitLocation.Office,
|
||||
StartTime = startTime,
|
||||
EndTime = endTime,
|
||||
IsPreview = true,
|
||||
ManualWorkedHours = Math.Round((decimal)(endTime - startTime).TotalHours, 2, MidpointRounding.AwayFromZero),
|
||||
CoeffSnapshot = new CoeffSnapshotDocument
|
||||
{
|
||||
StandardWorkHoursPerDay = settings.StandardWorkHoursPerDay,
|
||||
HourlyGrossRate = settings.HourlyGrossRate,
|
||||
ProfitabilityCoefficient = settings.ProfitabilityCoefficient,
|
||||
InpsRate = settings.InpsRate,
|
||||
SubstituteTaxRate = settings.SubstituteTaxRate
|
||||
},
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||
UpdatedAtUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
Compute(workUnit);
|
||||
return workUnit;
|
||||
}
|
||||
|
||||
private static void SortEntries(WorkDayDocument day)
|
||||
{
|
||||
day.WorkUnits = day.WorkUnits
|
||||
.OrderBy(unit => unit.StartTime ?? TimeOnly.MaxValue)
|
||||
.ThenBy(unit => unit.Label, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
day.CalendarEvents = day.CalendarEvents
|
||||
.OrderBy(calendarEvent => calendarEvent.StartTime ?? TimeOnly.MaxValue)
|
||||
.ThenBy(calendarEvent => calendarEvent.Description, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static WorkUnitDocument MapWorkUnit(DictionaryObject unit)
|
||||
{
|
||||
var workUnit = new WorkUnitDocument
|
||||
{
|
||||
Id = unit.GetString("id") ?? Guid.NewGuid().ToString("N"),
|
||||
Label = unit.GetString("label") ?? "Work unit",
|
||||
Location = unit.Contains("location") ? (WorkUnitLocation)unit.GetInt("location") : WorkUnitLocation.Office,
|
||||
StartTime = ReadTimeOnly(unit, "startTime"),
|
||||
EndTime = ReadTimeOnly(unit, "endTime"),
|
||||
IsPreview = unit.GetBoolean("isPreview"),
|
||||
ManualWorkedHours = ReadDecimal(unit, "manualWorkedHours", 0m),
|
||||
CalculatedWorkedHours = ReadDecimal(unit, "calculatedWorkedHours", 0m),
|
||||
WorkedHoursDelta = ReadDecimal(unit, "workedHoursDelta", 0m),
|
||||
GrossIncome = ReadDecimal(unit, "grossIncome", 0m),
|
||||
NetIncome = ReadDecimal(unit, "netIncome", 0m),
|
||||
Notes = unit.GetString("notes"),
|
||||
CoeffSnapshot = new CoeffSnapshotDocument
|
||||
{
|
||||
StandardWorkHoursPerDay = ReadDecimal(unit, "coeff_standardWorkHoursPerDay", 8m),
|
||||
HourlyGrossRate = ReadDecimal(unit, "coeff_hourlyGrossRate", 17.5m),
|
||||
ProfitabilityCoefficient = ReadDecimal(unit, "coeff_profitabilityCoefficient", 0.67m),
|
||||
InpsRate = ReadDecimal(unit, "coeff_inpsRate", 0.2607m),
|
||||
SubstituteTaxRate = ReadDecimal(unit, "coeff_substituteTaxRate", 0.15m)
|
||||
},
|
||||
CreatedAtUtc = ReadDateTimeOffset(unit, "createdAtUtc"),
|
||||
UpdatedAtUtc = ReadDateTimeOffset(unit, "updatedAtUtc")
|
||||
};
|
||||
|
||||
Compute(workUnit);
|
||||
return workUnit;
|
||||
}
|
||||
|
||||
private static CalendarEventDocument MapCalendarEvent(DictionaryObject calendarEvent)
|
||||
{
|
||||
var entry = new CalendarEventDocument
|
||||
{
|
||||
Id = calendarEvent.GetString("id") ?? Guid.NewGuid().ToString("N"),
|
||||
EventType = calendarEvent.Contains("eventType") ? (CalendarEventType)calendarEvent.GetInt("eventType") : CalendarEventType.Generic,
|
||||
Description = calendarEvent.GetString("description") ?? "Calendar entry",
|
||||
StartTime = ReadTimeOnly(calendarEvent, "startTime"),
|
||||
EndTime = ReadTimeOnly(calendarEvent, "endTime"),
|
||||
DurationHours = calendarEvent.Contains("durationHours") ? ReadDecimal(calendarEvent, "durationHours", 0m) : null,
|
||||
CreatedAtUtc = ReadDateTimeOffset(calendarEvent, "createdAtUtc"),
|
||||
UpdatedAtUtc = ReadDateTimeOffset(calendarEvent, "updatedAtUtc")
|
||||
};
|
||||
|
||||
Compute(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static WorkDayDocument MapLegacy(Document doc)
|
||||
{
|
||||
var date = DateOnly.ParseExact(doc.GetString("date") ?? doc.Id, "yyyy-MM-dd");
|
||||
var dayType = doc.Contains("dayType") ? (DayType)doc.GetInt("dayType") : DayType.None;
|
||||
var day = new WorkDayDocument
|
||||
{
|
||||
Id = doc.Id,
|
||||
Date = date,
|
||||
IsWeekend = doc.GetBoolean("isWeekend"),
|
||||
IsItalianFestivity = doc.GetBoolean("isItalianFestivity"),
|
||||
CreatedAtUtc = ReadDateTimeOffset(doc, "createdAtUtc"),
|
||||
UpdatedAtUtc = ReadDateTimeOffset(doc, "updatedAtUtc")
|
||||
};
|
||||
|
||||
var coeffSnapshot = new CoeffSnapshotDocument
|
||||
{
|
||||
StandardWorkHoursPerDay = ReadDecimal(doc, "coeff_standardWorkHoursPerDay", 8m),
|
||||
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)
|
||||
};
|
||||
|
||||
if (dayType is DayType.Work or DayType.Home)
|
||||
{
|
||||
var workUnit = new WorkUnitDocument
|
||||
{
|
||||
Id = "legacy",
|
||||
Label = "Legacy entry",
|
||||
Location = dayType == DayType.Home ? WorkUnitLocation.Home : WorkUnitLocation.Office,
|
||||
StartTime = ReadTimeOnly(doc, "startTime"),
|
||||
EndTime = ReadTimeOnly(doc, "actualExitTime") ?? ReadTimeOnly(doc, "projectedExitTime"),
|
||||
IsPreview = false,
|
||||
ManualWorkedHours = ReadDecimal(doc, "workedHoursFinal", ReadDecimal(doc, "workedHoursBase", 0m)),
|
||||
GrossIncome = ReadDecimal(doc, "grossIncome", 0m),
|
||||
NetIncome = ReadDecimal(doc, "netIncome", 0m),
|
||||
Notes = doc.GetString("notes"),
|
||||
CoeffSnapshot = coeffSnapshot,
|
||||
CreatedAtUtc = day.CreatedAtUtc,
|
||||
UpdatedAtUtc = day.UpdatedAtUtc
|
||||
};
|
||||
|
||||
Compute(workUnit);
|
||||
day.WorkUnits.Add(workUnit);
|
||||
}
|
||||
else if (dayType != DayType.None)
|
||||
{
|
||||
var calendarEvent = new CalendarEventDocument
|
||||
{
|
||||
Id = "legacy",
|
||||
EventType = MapLegacyEventType(dayType),
|
||||
Description = string.IsNullOrWhiteSpace(doc.GetString("notes")) ? $"Legacy {dayType}" : doc.GetString("notes")!,
|
||||
CreatedAtUtc = day.CreatedAtUtc,
|
||||
UpdatedAtUtc = day.UpdatedAtUtc
|
||||
};
|
||||
|
||||
Compute(calendarEvent);
|
||||
day.CalendarEvents.Add(calendarEvent);
|
||||
}
|
||||
|
||||
return day;
|
||||
}
|
||||
|
||||
private static CalendarEventType MapLegacyEventType(DayType dayType)
|
||||
{
|
||||
return dayType switch
|
||||
{
|
||||
DayType.DayOff => CalendarEventType.DayOff,
|
||||
DayType.Closure => CalendarEventType.Closure,
|
||||
DayType.Holiday => CalendarEventType.Holiday,
|
||||
DayType.Illness => CalendarEventType.Illness,
|
||||
_ => CalendarEventType.Generic
|
||||
};
|
||||
}
|
||||
|
||||
private static TimeOnly? ReadTimeOnly(Document doc, string key)
|
||||
{
|
||||
var value = doc.GetString(key);
|
||||
|
|
@ -220,6 +786,14 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
|||
: null;
|
||||
}
|
||||
|
||||
private static TimeOnly? ReadTimeOnly(DictionaryObject 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)
|
||||
|
|
@ -227,6 +801,13 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
|||
: defaultValue;
|
||||
}
|
||||
|
||||
private static decimal ReadDecimal(DictionaryObject 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);
|
||||
|
|
@ -234,4 +815,12 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
|||
? dt
|
||||
: DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
private static DateTimeOffset ReadDateTimeOffset(DictionaryObject doc, string key)
|
||||
{
|
||||
var value = doc.GetString(key);
|
||||
return !string.IsNullOrEmpty(value) && DateTimeOffset.TryParse(value, out var dt)
|
||||
? dt
|
||||
: DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,23 @@ public interface IWorkDayService
|
|||
{
|
||||
Task<WorkDayDocument?> GetAsync(DateOnly date, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<WorkDayDocument> SaveAsync(WorkDayDocument workDay, CancellationToken cancellationToken = default);
|
||||
Task<WorkUnitDocument?> GetWorkUnitAsync(DateOnly date, string workUnitId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CalendarEventDocument?> GetCalendarEventAsync(DateOnly date, string calendarEventId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<WorkUnitDocument> SaveWorkUnitAsync(DateOnly date, WorkUnitDocument workUnit, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CalendarEventDocument> SaveCalendarEventAsync(DateOnly date, CalendarEventDocument calendarEvent, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> DeleteWorkUnitAsync(DateOnly date, string workUnitId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> DeleteCalendarEventAsync(DateOnly date, string calendarEventId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<WorkDayDocument>> GetRangeAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<MonthlySummaryModel> GetMonthlySummaryAsync(int year, int month, CancellationToken cancellationToken = default);
|
||||
Task<MonthlySummaryModel> GetMonthlySummaryAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<MonthlyTimesheetModel> GetMonthlyTimesheetAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> GenerateMonthlyPreviewWorkUnitsAsync(int year, int month, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,16 +5,27 @@ services:
|
|||
dockerfile: Dockerfile
|
||||
image: ${IMAGE_REGISTRY:-worktracker}:${IMAGE_TAG:-latest}
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: Production
|
||||
UseHttpsRedirection: "false"
|
||||
ASPNETCORE_ENVIRONMENT: ${ASPNETCORE_ENVIRONMENT:-Production}
|
||||
ASPNETCORE_URLS: http://+:8080
|
||||
ASPNETCORE_FORWARDEDHEADERS_ENABLED: ${ASPNETCORE_FORWARDEDHEADERS_ENABLED:-true}
|
||||
AllowedHosts: ${ALLOWED_HOSTS:-*}
|
||||
UseHttpsRedirection: ${USE_HTTPS_REDIRECTION:-false}
|
||||
CouchbaseLite__DatabaseName: ${COUCHBASELITE_DATABASE_NAME:-worktracker}
|
||||
CouchbaseLite__Directory: /data/couchbase
|
||||
AppAuth__Enabled: ${APPAUTH_ENABLED:-false}
|
||||
AppAuth__DefaultUsername: ${APPAUTH_DEFAULT_USERNAME:-Admin}
|
||||
AppAuth__DefaultUserId: ${APPAUTH_DEFAULT_USERID:-ADMIN}
|
||||
SingleUser__SeedOnStartup: ${SINGLEUSER_SEED_ON_STARTUP:-true}
|
||||
SingleUser__Username: ${SINGLEUSER_USERNAME:-Admin}
|
||||
SingleUser__Password: ${SINGLEUSER_PASSWORD:-Disagio}
|
||||
ports:
|
||||
- "8002:8080"
|
||||
- "${WORKTRACKER_PORT:-8002}:8080"
|
||||
volumes:
|
||||
- ${WORKTRACKER_DATA_PATH:-./.docker-data/couchbase}:/data/couchbase
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
interval: ${WORKTRACKER_HEALTHCHECK_INTERVAL:-30s}
|
||||
timeout: ${WORKTRACKER_HEALTHCHECK_TIMEOUT:-5s}
|
||||
start_period: ${WORKTRACKER_HEALTHCHECK_START_PERIOD:-10s}
|
||||
retries: ${WORKTRACKER_HEALTHCHECK_RETRIES:-3}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,30 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('sidebar collapse', () => {
|
||||
test('starts collapsed and expands through the toggle button', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
await page.goto('/');
|
||||
|
||||
const sidebar = page.getByTestId('sidebar-shell');
|
||||
const toggle = page.getByRole('button', { name: 'Toggle sidebar' });
|
||||
|
||||
await expect(sidebar).toHaveAttribute('data-collapsed', 'true');
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(page.getByRole('link', { name: 'Dashboard' })).toBeVisible();
|
||||
|
||||
await toggle.click();
|
||||
|
||||
await expect(sidebar).toHaveAttribute('data-collapsed', 'false');
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(page.getByText('Dashboard')).toBeVisible();
|
||||
|
||||
await toggle.click();
|
||||
|
||||
await expect(sidebar).toHaveAttribute('data-collapsed', 'true');
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
});
|
||||
|
||||
test('home loads without a login screen', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
|
|
@ -14,7 +39,7 @@ test('protected pages are directly available without redirecting to login', asyn
|
|||
{ path: '/calendar', heading: 'Calendar' },
|
||||
{ path: '/summary', heading: 'Monthly Summary' },
|
||||
{ path: '/settings', heading: 'Settings' },
|
||||
{ path: '/workday', heading: 'Work Day Entry' },
|
||||
{ path: '/work-unit', heading: 'Work Unit' },
|
||||
{ path: '/auth', heading: 'You are authenticated' }
|
||||
];
|
||||
|
||||
|
|
|
|||
261
wwwroot/app.css
261
wwwroot/app.css
|
|
@ -61,20 +61,35 @@ h1:focus {
|
|||
|
||||
/* Calendar view */
|
||||
.calendar-table td.calendar-cell {
|
||||
height: 5rem;
|
||||
height: 10rem;
|
||||
vertical-align: top;
|
||||
padding: 0.25rem 0.4rem;
|
||||
cursor: pointer;
|
||||
min-width: 5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-table td.calendar-cell:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.calendar-cell-active {
|
||||
box-shadow: inset 0 0 0 0.15rem #1b6ec2;
|
||||
}
|
||||
|
||||
.calendar-day-number {
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.calendar-day-total {
|
||||
margin-top: auto;
|
||||
padding-top: 0.25rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.calendar-hours {
|
||||
|
|
@ -82,6 +97,118 @@ h1:focus {
|
|||
color: #666;
|
||||
}
|
||||
|
||||
.calendar-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 0.45rem;
|
||||
font-size: 0.72rem;
|
||||
margin-bottom: 0.2rem;
|
||||
padding: 0.2rem 0.35rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.calendar-item-work {
|
||||
color: #14213d;
|
||||
}
|
||||
|
||||
.calendar-item-office {
|
||||
background-color: #cfe2ff;
|
||||
}
|
||||
|
||||
.calendar-item-home {
|
||||
background-color: #d1e7dd;
|
||||
}
|
||||
|
||||
.calendar-item-preview-office {
|
||||
background-color: rgba(207, 226, 255, 0.55);
|
||||
border: 1px dashed #6c8ebf;
|
||||
}
|
||||
|
||||
.calendar-item-preview-home {
|
||||
background-color: rgba(209, 231, 221, 0.55);
|
||||
border: 1px dashed #5b8a72;
|
||||
}
|
||||
|
||||
.calendar-item-event {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.calendar-item-generic {
|
||||
background-color: #6c757d;
|
||||
}
|
||||
|
||||
.calendar-item-dayoff {
|
||||
background-color: #6c757d;
|
||||
}
|
||||
|
||||
.calendar-item-closure {
|
||||
background-color: #b08900;
|
||||
}
|
||||
|
||||
.calendar-item-holiday {
|
||||
background-color: #b02a37;
|
||||
}
|
||||
|
||||
.calendar-item-illness {
|
||||
background-color: #0c8599;
|
||||
}
|
||||
|
||||
.calendar-popup {
|
||||
position: absolute;
|
||||
top: 2rem;
|
||||
left: 0.35rem;
|
||||
z-index: 20;
|
||||
width: min(16rem, calc(100vw - 2rem));
|
||||
max-width: calc(100vw - 2rem);
|
||||
background: #fff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 0.75rem 2rem rgba(0, 0, 0, 0.18);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.calendar-popup-right {
|
||||
left: auto;
|
||||
right: 0.35rem;
|
||||
}
|
||||
|
||||
.calendar-popup-left {
|
||||
left: 0.35rem;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
.calendar-popup-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.calendar-popup-link {
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
background: #f1f3f5;
|
||||
padding: 0.45rem 0.6rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.calendar-legend-work {
|
||||
background-color: #cfe2ff;
|
||||
color: #14213d;
|
||||
}
|
||||
|
||||
.calendar-legend-home {
|
||||
background-color: #d1e7dd;
|
||||
color: #1d3b2a;
|
||||
}
|
||||
|
||||
.calendar-legend-preview {
|
||||
background-color: #fff3cd;
|
||||
color: #6b4f00;
|
||||
}
|
||||
|
||||
.calendar-weekend {
|
||||
background-color: #ffe0e0 !important;
|
||||
}
|
||||
|
|
@ -100,4 +227,136 @@ h1:focus {
|
|||
|
||||
.calendar-holiday {
|
||||
background-color: #d4edda !important;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.calendar-table td.calendar-cell {
|
||||
height: 8rem;
|
||||
min-width: 7rem;
|
||||
}
|
||||
|
||||
.calendar-popup {
|
||||
left: 0;
|
||||
width: calc(100vw - 2rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* Monthly timesheet summary */
|
||||
.timesheet-summary-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timesheet-summary-table {
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.timesheet-summary-table thead th {
|
||||
background-color: #f8f9fa;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timesheet-summary-table th,
|
||||
.timesheet-summary-table td {
|
||||
min-width: 2.2rem;
|
||||
padding: 0.25rem 0.12rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.timesheet-summary-sticky-column {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
min-width: 15rem !important;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.timesheet-summary-table thead .timesheet-summary-sticky-column {
|
||||
z-index: 3;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.timesheet-summary-total-column {
|
||||
background-color: #f8f9fa;
|
||||
min-width: 3.3rem !important;
|
||||
}
|
||||
|
||||
.timesheet-summary-table tbody tr:nth-child(odd) td,
|
||||
.timesheet-summary-table tbody tr:nth-child(odd) .timesheet-summary-sticky-column {
|
||||
background-color: #fcfcfd;
|
||||
}
|
||||
|
||||
.timesheet-summary-table .timesheet-summary-day-danger {
|
||||
background-color: #f8d7da !important;
|
||||
}
|
||||
|
||||
.timesheet-summary-table .timesheet-summary-day-closure {
|
||||
background-color: #e2e3e5 !important;
|
||||
}
|
||||
|
||||
.timesheet-summary-day-header {
|
||||
position: relative;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.timesheet-summary-day-popup {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.35rem);
|
||||
left: 50%;
|
||||
z-index: 15;
|
||||
width: min(18rem, calc(100vw - 2rem));
|
||||
max-width: calc(100vw - 2rem);
|
||||
padding: 0.65rem 0.75rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 0.7rem;
|
||||
background: #fff;
|
||||
box-shadow: 0 0.75rem 2rem rgba(15, 23, 42, 0.18);
|
||||
text-align: left;
|
||||
transform: translateX(-50%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.timesheet-summary-day-header:hover .timesheet-summary-day-popup,
|
||||
.timesheet-summary-day-header:focus-within .timesheet-summary-day-popup {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.timesheet-summary-day-popup-left .timesheet-summary-day-popup {
|
||||
left: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.timesheet-summary-day-popup-right .timesheet-summary-day-popup {
|
||||
left: auto;
|
||||
right: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.timesheet-summary-day-popup-item {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.35;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.timesheet-summary-day-popup-item + .timesheet-summary-day-popup-item {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.timesheet-summary-day-popup-item-event {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.timesheet-summary-sticky-column {
|
||||
min-width: 12rem !important;
|
||||
}
|
||||
|
||||
.timesheet-summary-day-popup {
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: auto;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue