diff --git a/.vscode/launch.json b/.vscode/launch.json index 2b0caf4..4e1e71d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -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", diff --git a/Components/App.razor b/Components/App.razor index 1e8963d..a2917af 100644 --- a/Components/App.razor +++ b/Components/App.razor @@ -14,7 +14,7 @@ - + diff --git a/Components/Layout/MainLayout.razor b/Components/Layout/MainLayout.razor index 78624f3..7e9232b 100644 --- a/Components/Layout/MainLayout.razor +++ b/Components/Layout/MainLayout.razor @@ -1,12 +1,24 @@ @inherits LayoutComponentBase -
- +
+ + +
+ +
+ + +
+ @if (loading) {

Loading...

} -else if (summary is not null) +else if (viewMode == SummaryViewMode.Cards && summary is not null) {
@@ -30,11 +41,35 @@ else if (summary is not null)
+
+
+
+
Counted Work Units
+
@summary.CountedWorkUnits
+
+
+
Total Worked Hours
-
@summary.TotalWorkedHours.ToString("N1")h
+
@FormatHours(summary.TotalWorkedHours)
+
+
+
+
+
+
+
Preview Hours
+
@FormatHours(summary.TotalPreviewWorkedHours)
+
+
+
+
+
+
+
Preview Units
+
@summary.PreviewWorkUnits
@@ -42,7 +77,7 @@ else if (summary is not null)
Hours Off
-
@summary.TotalHoursOff.ToString("N1")h
+
@FormatHours(summary.TotalHoursOff)
@@ -89,8 +124,8 @@ else if (summary is not null)
-
Sick Days
-
@summary.SickDays
+
Closure Days
+
@summary.ClosureDays
@@ -105,20 +140,82 @@ else if (summary is not null)
-
Closure Days
-
@summary.ClosureDays
+
Sick Days
+
@summary.SickDays
} +else if (viewMode == SummaryViewMode.Timesheet && timesheet is not null) +{ +
+
+
+ + + + + @for (var i = 0; i < timesheet.Days.Count; i++) + { + var day = timesheet.Days[i]; + + } + + + + + @foreach (var row in timesheet.Rows) + { + + + @for (var i = 0; i < row.DailyValues.Count; i++) + { + + } + + + } + +
Categoria +
@day.Date.Day
+
@GetDayHeader(day.Date)
+
+
@day.Date.ToString("dddd d MMMM", ItalianCulture)
+ @if (day.WorkUnitSummaries.Count == 0 && day.EventSummaries.Count == 0) + { +
Nessun elemento registrato.
+ } + else + { + @foreach (var workUnit in day.WorkUnitSummaries) + { +
@workUnit
+ } + + @foreach (var calendarEvent in day.EventSummaries) + { +
@calendarEvent
+ } + } +
+
Totale
@row.Label@FormatTimesheetValue(row.DailyValues[i], row.ValueFormat)@FormatTimesheetValue(row.Total, row.ValueFormat)
+
+
+
+} @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 + } } diff --git a/Components/Pages/Settings.razor b/Components/Pages/Settings.razor index 5034b2d..0cb3a2d 100644 --- a/Components/Pages/Settings.razor +++ b/Components/Pages/Settings.razor @@ -6,7 +6,7 @@ Settings

Settings

-

Default values used to prefill each workday. Every day can still override these values.

+

Default values used to compute manual work-unit totals and income.

@if (settings is null) { @@ -22,10 +22,6 @@ else -
- - -
diff --git a/Components/Pages/WorkDayEditor.razor b/Components/Pages/WorkDayEditor.razor index 58108a5..c54ec93 100644 --- a/Components/Pages/WorkDayEditor.razor +++ b/Components/Pages/WorkDayEditor.razor @@ -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 -Work Day +Work Unit -

Work Day Entry

+

Work Unit

@if (!loaded) { @@ -21,22 +22,20 @@ else
- - @if (isWeekend || isFestivity) - { -
- @if (isWeekend) { Weekend } - @if (isFestivity) { Festivity } -
- } +
- - +
+ +
+ +
@@ -47,14 +46,20 @@ else
- - -
Informational only, not used in calculations.
+ +
- - + + +
+ +
+
+ + +
@@ -68,20 +73,12 @@ else

Computed values

- -
@(projectedExitTime?.ToString("HH:mm") ?? "—")
+ +
@FormatHours(calculatedWorkedHours)
- -
@workedHoursBase.ToString("N2")h
-
-
- -
@workedHoursFinal.ToString("N2")h
-
-
- -
@hoursOff.ToString("N2")h
+ +
@FormatSignedHours(workedHoursDelta)
@@ -93,8 +90,27 @@ else
+
+

Day Total

+
+
+ +
@FormatHours(dayTotalHours)
+
+
+ +
@dayWorkUnitCount
+
+
+
+
+ @if (isExistingUnit) + { + + } + @if (!string.IsNullOrWhiteSpace(statusMessage)) { @statusMessage @@ -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 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(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("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(); } } diff --git a/Domain/AppSettingsDocument.cs b/Domain/AppSettingsDocument.cs index d7fa2a1..a3a93d1 100644 --- a/Domain/AppSettingsDocument.cs +++ b/Domain/AppSettingsDocument.cs @@ -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; diff --git a/Domain/CalendarEventDocument.cs b/Domain/CalendarEventDocument.cs new file mode 100644 index 0000000..22fb24f --- /dev/null +++ b/Domain/CalendarEventDocument.cs @@ -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; +} \ No newline at end of file diff --git a/Domain/CalendarEventType.cs b/Domain/CalendarEventType.cs new file mode 100644 index 0000000..7a7b6b9 --- /dev/null +++ b/Domain/CalendarEventType.cs @@ -0,0 +1,10 @@ +namespace WorkTracker.Domain; + +public enum CalendarEventType +{ + Generic = 0, + DayOff = 1, + Closure = 2, + Holiday = 3, + Illness = 4 +} \ No newline at end of file diff --git a/Domain/CoeffSnapshotDocument.cs b/Domain/CoeffSnapshotDocument.cs index 220be3a..347866f 100644 --- a/Domain/CoeffSnapshotDocument.cs +++ b/Domain/CoeffSnapshotDocument.cs @@ -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; diff --git a/Domain/MonthlySummaryModel.cs b/Domain/MonthlySummaryModel.cs index 58cde37..254a7c9 100644 --- a/Domain/MonthlySummaryModel.cs +++ b/Domain/MonthlySummaryModel.cs @@ -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; } diff --git a/Domain/MonthlyTimesheetDaySummary.cs b/Domain/MonthlyTimesheetDaySummary.cs new file mode 100644 index 0000000..e7687f7 --- /dev/null +++ b/Domain/MonthlyTimesheetDaySummary.cs @@ -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; } +} \ No newline at end of file diff --git a/Domain/MonthlyTimesheetModel.cs b/Domain/MonthlyTimesheetModel.cs new file mode 100644 index 0000000..5b0ff34 --- /dev/null +++ b/Domain/MonthlyTimesheetModel.cs @@ -0,0 +1,46 @@ +namespace WorkTracker.Domain; + +public sealed class MonthlyTimesheetModel +{ + public int Year { get; set; } + + public int Month { get; set; } + + public List Days { get; set; } = []; + + public List 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 WorkUnitSummaries { get; set; } = []; + + public List 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 DailyValues { get; set; } = []; + + public decimal? Total { get; set; } +} + +public enum MonthlyTimesheetValueFormat +{ + Hours = 0, + Days = 1 +} \ No newline at end of file diff --git a/Domain/WorkDayDocument.cs b/Domain/WorkDayDocument.cs index 664674e..f56d4b8 100644 --- a/Domain/WorkDayDocument.cs +++ b/Domain/WorkDayDocument.cs @@ -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 WorkUnits { get; set; } = []; - public CoeffSnapshotDocument CoeffSnapshot { get; set; } = new(); + public List CalendarEvents { get; set; } = []; public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; diff --git a/Domain/WorkUnitDocument.cs b/Domain/WorkUnitDocument.cs new file mode 100644 index 0000000..3803c61 --- /dev/null +++ b/Domain/WorkUnitDocument.cs @@ -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; +} \ No newline at end of file diff --git a/Domain/WorkUnitLocation.cs b/Domain/WorkUnitLocation.cs new file mode 100644 index 0000000..6c40a05 --- /dev/null +++ b/Domain/WorkUnitLocation.cs @@ -0,0 +1,7 @@ +namespace WorkTracker.Domain; + +public enum WorkUnitLocation +{ + Office = 1, + Home = 2 +} \ No newline at end of file diff --git a/README.Docker.md b/README.Docker.md index 67a7a0d..7cfede9 100644 --- a/README.Docker.md +++ b/README.Docker.md @@ -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. diff --git a/Services/Settings/CouchbaseLiteAppSettingsService.cs b/Services/Settings/CouchbaseLiteAppSettingsService.cs index 66e8040..fc934bf 100644 --- a/Services/Settings/CouchbaseLiteAppSettingsService.cs +++ b/Services/Settings/CouchbaseLiteAppSettingsService.cs @@ -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), diff --git a/Services/WorkDays/CouchbaseLiteWorkDayService.cs b/Services/WorkDays/CouchbaseLiteWorkDayService.cs index bfc458d..27a9b1d 100644 --- a/Services/WorkDays/CouchbaseLiteWorkDayService.cs +++ b/Services/WorkDays/CouchbaseLiteWorkDayService.cs @@ -31,45 +31,132 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService return Task.FromResult(doc is not null ? Map(doc) : null); } - public async Task SaveAsync(WorkDayDocument workDay, CancellationToken cancellationToken = default) + public async Task 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 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 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 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 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 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> GetRangeAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default) @@ -90,88 +177,180 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService return Task.FromResult>(results); } - public async Task GetMonthlySummaryAsync(int year, int month, CancellationToken cancellationToken = default) + public async Task 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 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(); + 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 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(); + 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(); + 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 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 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 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; + } } diff --git a/Services/WorkDays/IWorkDayService.cs b/Services/WorkDays/IWorkDayService.cs index 658fcd9..c6bc5b8 100644 --- a/Services/WorkDays/IWorkDayService.cs +++ b/Services/WorkDays/IWorkDayService.cs @@ -6,9 +6,23 @@ public interface IWorkDayService { Task GetAsync(DateOnly date, CancellationToken cancellationToken = default); - Task SaveAsync(WorkDayDocument workDay, CancellationToken cancellationToken = default); + Task GetWorkUnitAsync(DateOnly date, string workUnitId, CancellationToken cancellationToken = default); + + Task GetCalendarEventAsync(DateOnly date, string calendarEventId, CancellationToken cancellationToken = default); + + Task SaveWorkUnitAsync(DateOnly date, WorkUnitDocument workUnit, CancellationToken cancellationToken = default); + + Task SaveCalendarEventAsync(DateOnly date, CalendarEventDocument calendarEvent, CancellationToken cancellationToken = default); + + Task DeleteWorkUnitAsync(DateOnly date, string workUnitId, CancellationToken cancellationToken = default); + + Task DeleteCalendarEventAsync(DateOnly date, string calendarEventId, CancellationToken cancellationToken = default); Task> GetRangeAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default); - Task GetMonthlySummaryAsync(int year, int month, CancellationToken cancellationToken = default); + Task GetMonthlySummaryAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default); + + Task GetMonthlyTimesheetAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default); + + Task GenerateMonthlyPreviewWorkUnitsAsync(int year, int month, CancellationToken cancellationToken = default); } diff --git a/docker-compose.yml b/docker-compose.yml index 08d2020..981a99f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/tests/playwright/auth-bypass.spec.ts b/tests/playwright/auth-bypass.spec.ts index 5250760..670b5e8 100644 --- a/tests/playwright/auth-bypass.spec.ts +++ b/tests/playwright/auth-bypass.spec.ts @@ -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' } ]; diff --git a/wwwroot/app.css b/wwwroot/app.css index 210fb13..071fd5d 100644 --- a/wwwroot/app.css +++ b/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; + } } \ No newline at end of file