Some checks failed
Publish Container / publish (push) Failing after 1m2s
- Implement GridView.razor for displaying a tabular view of workdays in the current month. - Create MonthlySummary.razor to show a summary of worked hours, income, and day types for the selected month. - Introduce WorkDayEditor.razor for adding and editing workday entries with detailed calculations. - Update Home.razor to include links to the new Grid View and Monthly Summary pages. - Add IWorkDayService interface and CouchbaseLiteWorkDayService implementation for managing workday data. - Define domain models: WorkDayDocument, MonthlySummaryModel, and CoeffSnapshotDocument for data structure. - Enhance CouchbaseLiteDatabaseProvider to include a collection for workdays. - Update app settings and services to support new features. - Add CSS styles for calendar view and table formatting.
237 lines
10 KiB
C#
237 lines
10 KiB
C#
using Couchbase.Lite;
|
|
using WorkTracker.Domain;
|
|
using WorkTracker.Services.Festivities;
|
|
using WorkTracker.Services.Settings;
|
|
using WorkTracker.Services.Storage;
|
|
|
|
namespace WorkTracker.Services.WorkDays;
|
|
|
|
public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
|
{
|
|
private readonly Collection workDaysCollection;
|
|
private readonly IAppSettingsService appSettingsService;
|
|
private readonly IItalianFestivitySource festivitySource;
|
|
|
|
public CouchbaseLiteWorkDayService(
|
|
CouchbaseLiteDatabaseProvider databaseProvider,
|
|
IAppSettingsService appSettingsService,
|
|
IItalianFestivitySource festivitySource)
|
|
{
|
|
workDaysCollection = databaseProvider.WorkDays;
|
|
this.appSettingsService = appSettingsService;
|
|
this.festivitySource = festivitySource;
|
|
}
|
|
|
|
public Task<WorkDayDocument?> GetAsync(DateOnly date, CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var id = date.ToString("yyyy-MM-dd");
|
|
var doc = workDaysCollection.GetDocument(id);
|
|
return Task.FromResult(doc is not null ? Map(doc) : null);
|
|
}
|
|
|
|
public async Task<WorkDayDocument> SaveAsync(WorkDayDocument workDay, CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var settings = await appSettingsService.GetAsync(cancellationToken);
|
|
var festivities = festivitySource.GetFestivities(workDay.Date.Year);
|
|
|
|
workDay.Id = workDay.Date.ToString("yyyy-MM-dd");
|
|
workDay.IsWeekend = workDay.Date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
|
|
workDay.IsItalianFestivity = festivities.Contains(workDay.Date);
|
|
|
|
// Snapshot coefficients from current settings
|
|
workDay.CoeffSnapshot = new CoeffSnapshotDocument
|
|
{
|
|
StandardWorkHoursPerDay = settings.StandardWorkHoursPerDay,
|
|
LunchBreakHours = settings.LunchBreakHours,
|
|
HourlyGrossRate = settings.HourlyGrossRate,
|
|
ProfitabilityCoefficient = settings.ProfitabilityCoefficient,
|
|
InpsRate = settings.InpsRate,
|
|
SubstituteTaxRate = settings.SubstituteTaxRate
|
|
};
|
|
|
|
Compute(workDay);
|
|
|
|
// Preserve creation timestamp for existing documents
|
|
var existing = workDaysCollection.GetDocument(workDay.Id);
|
|
if (existing is not null)
|
|
{
|
|
workDay.CreatedAtUtc = ReadDateTimeOffset(existing, "createdAtUtc");
|
|
}
|
|
else
|
|
{
|
|
workDay.CreatedAtUtc = DateTimeOffset.UtcNow;
|
|
}
|
|
|
|
workDay.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
|
|
|
SaveDocument(workDay);
|
|
return workDay;
|
|
}
|
|
|
|
public Task<IReadOnlyList<WorkDayDocument>> GetRangeAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var results = new List<WorkDayDocument>();
|
|
for (var date = from; date <= to; date = date.AddDays(1))
|
|
{
|
|
var id = date.ToString("yyyy-MM-dd");
|
|
var doc = workDaysCollection.GetDocument(id);
|
|
if (doc is not null)
|
|
{
|
|
results.Add(Map(doc));
|
|
}
|
|
}
|
|
|
|
return Task.FromResult<IReadOnlyList<WorkDayDocument>>(results);
|
|
}
|
|
|
|
public async Task<MonthlySummaryModel> GetMonthlySummaryAsync(int year, int month, CancellationToken cancellationToken = default)
|
|
{
|
|
var from = new DateOnly(year, month, 1);
|
|
var to = from.AddMonths(1).AddDays(-1);
|
|
var days = await GetRangeAsync(from, to, cancellationToken);
|
|
|
|
return new MonthlySummaryModel
|
|
{
|
|
Year = year,
|
|
Month = month,
|
|
TotalWorkedHours = days.Sum(d => d.WorkedHoursFinal),
|
|
OfficeDays = days.Count(d => d.DayType == DayType.Work),
|
|
HomeDays = days.Count(d => d.DayType == DayType.Home),
|
|
HolidayDays = days.Count(d => d.DayType == DayType.Holiday),
|
|
SickDays = days.Count(d => d.DayType == DayType.Illness),
|
|
DaysOff = days.Count(d => d.DayType == DayType.DayOff),
|
|
ClosureDays = days.Count(d => d.DayType == DayType.Closure),
|
|
TotalHoursOff = days.Sum(d => d.HoursOff),
|
|
TotalGrossIncome = days.Sum(d => d.GrossIncome),
|
|
TotalNetIncome = days.Sum(d => d.NetIncome),
|
|
TotalWorkingDays = days.Count(d => d.DayType is DayType.Work or DayType.Home)
|
|
};
|
|
}
|
|
|
|
private static void Compute(WorkDayDocument day)
|
|
{
|
|
var coeff = day.CoeffSnapshot;
|
|
|
|
// Calculate projected exit time
|
|
if (day.StartTime.HasValue && day.DayType is DayType.Work or DayType.Home)
|
|
{
|
|
var totalHours = coeff.StandardWorkHoursPerDay + coeff.LunchBreakHours;
|
|
day.ProjectedExitTime = day.StartTime.Value.Add(TimeSpan.FromHours((double)totalHours));
|
|
}
|
|
else
|
|
{
|
|
day.ProjectedExitTime = null;
|
|
}
|
|
|
|
// Calculate worked hours
|
|
day.WorkedHoursBase = day.DayType is DayType.Work or DayType.Home
|
|
? coeff.StandardWorkHoursPerDay
|
|
: 0m;
|
|
|
|
day.WorkedHoursFinal = day.WorkedHoursBase + day.ExtraHoursDelta;
|
|
|
|
// Hours off (only for work/home days)
|
|
day.HoursOff = day.DayType is DayType.Work or DayType.Home
|
|
? Math.Max(0m, coeff.StandardWorkHoursPerDay - day.WorkedHoursFinal)
|
|
: 0m;
|
|
|
|
// Income calculations
|
|
day.GrossIncome = day.WorkedHoursFinal * coeff.HourlyGrossRate;
|
|
var taxableBase = day.GrossIncome * coeff.ProfitabilityCoefficient;
|
|
day.NetIncome = day.GrossIncome - (taxableBase * coeff.InpsRate) - (taxableBase * coeff.SubstituteTaxRate);
|
|
}
|
|
|
|
private void SaveDocument(WorkDayDocument day)
|
|
{
|
|
var doc = new MutableDocument(day.Id);
|
|
doc.SetString("date", day.Date.ToString("yyyy-MM-dd"));
|
|
doc.SetString("startTime", day.StartTime?.ToString("HH:mm"));
|
|
doc.SetString("projectedExitTime", day.ProjectedExitTime?.ToString("HH:mm"));
|
|
doc.SetString("actualExitTime", day.ActualExitTime?.ToString("HH:mm"));
|
|
doc.SetInt("dayType", (int)day.DayType);
|
|
doc.SetDouble("extraHoursDelta", decimal.ToDouble(day.ExtraHoursDelta));
|
|
doc.SetDouble("workedHoursBase", decimal.ToDouble(day.WorkedHoursBase));
|
|
doc.SetDouble("workedHoursFinal", decimal.ToDouble(day.WorkedHoursFinal));
|
|
doc.SetDouble("hoursOff", decimal.ToDouble(day.HoursOff));
|
|
doc.SetDouble("grossIncome", decimal.ToDouble(day.GrossIncome));
|
|
doc.SetDouble("netIncome", decimal.ToDouble(day.NetIncome));
|
|
doc.SetBoolean("isWeekend", day.IsWeekend);
|
|
doc.SetBoolean("isItalianFestivity", day.IsItalianFestivity);
|
|
doc.SetString("notes", day.Notes);
|
|
|
|
// Coefficient snapshot
|
|
doc.SetDouble("coeff_standardWorkHoursPerDay", decimal.ToDouble(day.CoeffSnapshot.StandardWorkHoursPerDay));
|
|
doc.SetDouble("coeff_lunchBreakHours", decimal.ToDouble(day.CoeffSnapshot.LunchBreakHours));
|
|
doc.SetDouble("coeff_hourlyGrossRate", decimal.ToDouble(day.CoeffSnapshot.HourlyGrossRate));
|
|
doc.SetDouble("coeff_profitabilityCoefficient", decimal.ToDouble(day.CoeffSnapshot.ProfitabilityCoefficient));
|
|
doc.SetDouble("coeff_inpsRate", decimal.ToDouble(day.CoeffSnapshot.InpsRate));
|
|
doc.SetDouble("coeff_substituteTaxRate", decimal.ToDouble(day.CoeffSnapshot.SubstituteTaxRate));
|
|
|
|
doc.SetString("createdAtUtc", day.CreatedAtUtc.ToString("O"));
|
|
doc.SetString("updatedAtUtc", day.UpdatedAtUtc.ToString("O"));
|
|
|
|
workDaysCollection.Save(doc);
|
|
}
|
|
|
|
private static WorkDayDocument Map(Document doc)
|
|
{
|
|
return new WorkDayDocument
|
|
{
|
|
Id = doc.Id,
|
|
Date = DateOnly.ParseExact(doc.GetString("date") ?? doc.Id, "yyyy-MM-dd"),
|
|
StartTime = ReadTimeOnly(doc, "startTime"),
|
|
ProjectedExitTime = ReadTimeOnly(doc, "projectedExitTime"),
|
|
ActualExitTime = ReadTimeOnly(doc, "actualExitTime"),
|
|
DayType = (DayType)doc.GetInt("dayType"),
|
|
ExtraHoursDelta = Convert.ToDecimal(doc.GetDouble("extraHoursDelta")),
|
|
WorkedHoursBase = Convert.ToDecimal(doc.GetDouble("workedHoursBase")),
|
|
WorkedHoursFinal = Convert.ToDecimal(doc.GetDouble("workedHoursFinal")),
|
|
HoursOff = Convert.ToDecimal(doc.GetDouble("hoursOff")),
|
|
GrossIncome = Convert.ToDecimal(doc.GetDouble("grossIncome")),
|
|
NetIncome = Convert.ToDecimal(doc.GetDouble("netIncome")),
|
|
IsWeekend = doc.GetBoolean("isWeekend"),
|
|
IsItalianFestivity = doc.GetBoolean("isItalianFestivity"),
|
|
Notes = doc.GetString("notes"),
|
|
CoeffSnapshot = new CoeffSnapshotDocument
|
|
{
|
|
StandardWorkHoursPerDay = ReadDecimal(doc, "coeff_standardWorkHoursPerDay", 8m),
|
|
LunchBreakHours = ReadDecimal(doc, "coeff_lunchBreakHours", 1m),
|
|
HourlyGrossRate = ReadDecimal(doc, "coeff_hourlyGrossRate", 17.5m),
|
|
ProfitabilityCoefficient = ReadDecimal(doc, "coeff_profitabilityCoefficient", 0.67m),
|
|
InpsRate = ReadDecimal(doc, "coeff_inpsRate", 0.2607m),
|
|
SubstituteTaxRate = ReadDecimal(doc, "coeff_substituteTaxRate", 0.15m)
|
|
},
|
|
CreatedAtUtc = ReadDateTimeOffset(doc, "createdAtUtc"),
|
|
UpdatedAtUtc = ReadDateTimeOffset(doc, "updatedAtUtc")
|
|
};
|
|
}
|
|
|
|
private static TimeOnly? ReadTimeOnly(Document doc, string key)
|
|
{
|
|
var value = doc.GetString(key);
|
|
return !string.IsNullOrEmpty(value) && TimeOnly.TryParseExact(value, "HH:mm", out var time)
|
|
? time
|
|
: null;
|
|
}
|
|
|
|
private static decimal ReadDecimal(Document doc, string key, decimal defaultValue)
|
|
{
|
|
return doc.Contains(key)
|
|
? Convert.ToDecimal(doc.GetDouble(key))
|
|
: defaultValue;
|
|
}
|
|
|
|
private static DateTimeOffset ReadDateTimeOffset(Document doc, string key)
|
|
{
|
|
var value = doc.GetString(key);
|
|
return !string.IsNullOrEmpty(value) && DateTimeOffset.TryParse(value, out var dt)
|
|
? dt
|
|
: DateTimeOffset.UtcNow;
|
|
}
|
|
}
|