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