Refactor AppSettingsDocument and CoeffSnapshotDocument: Remove LunchBreakHours property
Add CalendarEventDocument and CalendarEventType enum for event management Update WorkDayDocument to include WorkUnitDocument and CalendarEventDocument lists Enhance CouchbaseLiteWorkDayService with methods for managing WorkUnit and CalendarEvent Revise MonthlySummaryModel to track preview worked hours and counted work units Improve CSS for calendar view, including responsive design and new item styles
This commit is contained in:
parent
08e573d63c
commit
cab549ab3a
22 changed files with 1725 additions and 356 deletions
|
|
@ -48,7 +48,6 @@ public sealed class CouchbaseLiteAppSettingsService : IAppSettingsService
|
|||
{
|
||||
var document = new MutableDocument(DefaultSettingsId);
|
||||
document.SetDouble("standardWorkHoursPerDay", Decimal.ToDouble(settings.StandardWorkHoursPerDay));
|
||||
document.SetDouble("lunchBreakHours", Decimal.ToDouble(settings.LunchBreakHours));
|
||||
document.SetDouble("hourlyGrossRate", Decimal.ToDouble(settings.HourlyGrossRate));
|
||||
document.SetDouble("profitabilityCoefficient", Decimal.ToDouble(settings.ProfitabilityCoefficient));
|
||||
document.SetDouble("inpsRate", Decimal.ToDouble(settings.InpsRate));
|
||||
|
|
@ -67,7 +66,6 @@ public sealed class CouchbaseLiteAppSettingsService : IAppSettingsService
|
|||
{
|
||||
Id = document.Id,
|
||||
StandardWorkHoursPerDay = ReadDecimal(document, "standardWorkHoursPerDay", 8m),
|
||||
LunchBreakHours = ReadDecimal(document, "lunchBreakHours", 1m),
|
||||
HourlyGrossRate = ReadDecimal(document, "hourlyGrossRate", 17.5m),
|
||||
ProfitabilityCoefficient = ReadDecimal(document, "profitabilityCoefficient", 0.67m),
|
||||
InpsRate = ReadDecimal(document, "inpsRate", 0.2607m),
|
||||
|
|
|
|||
|
|
@ -31,45 +31,132 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
|||
return Task.FromResult(doc is not null ? Map(doc) : null);
|
||||
}
|
||||
|
||||
public async Task<WorkDayDocument> SaveAsync(WorkDayDocument workDay, CancellationToken cancellationToken = default)
|
||||
public async Task<WorkUnitDocument?> GetWorkUnitAsync(DateOnly date, string workUnitId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var day = await GetAsync(date, cancellationToken);
|
||||
return day?.WorkUnits.FirstOrDefault(unit => string.Equals(unit.Id, workUnitId, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
public async Task<CalendarEventDocument?> GetCalendarEventAsync(DateOnly date, string calendarEventId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var day = await GetAsync(date, cancellationToken);
|
||||
return day?.CalendarEvents.FirstOrDefault(calendarEvent => string.Equals(calendarEvent.Id, calendarEventId, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
public async Task<WorkUnitDocument> SaveWorkUnitAsync(DateOnly date, WorkUnitDocument workUnit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var settings = await appSettingsService.GetAsync(cancellationToken);
|
||||
var festivities = festivitySource.GetFestivities(workDay.Date.Year);
|
||||
var day = await GetOrCreateDayAsync(date, cancellationToken);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var existingIndex = day.WorkUnits.FindIndex(unit => string.Equals(unit.Id, workUnit.Id, StringComparison.Ordinal));
|
||||
var existingCreatedAt = existingIndex >= 0 ? day.WorkUnits[existingIndex].CreatedAtUtc : now;
|
||||
|
||||
workDay.Id = workDay.Date.ToString("yyyy-MM-dd");
|
||||
workDay.IsWeekend = workDay.Date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
|
||||
workDay.IsItalianFestivity = festivities.Contains(workDay.Date);
|
||||
|
||||
// Snapshot coefficients from current settings
|
||||
workDay.CoeffSnapshot = new CoeffSnapshotDocument
|
||||
workUnit.Id = string.IsNullOrWhiteSpace(workUnit.Id) ? Guid.NewGuid().ToString("N") : workUnit.Id;
|
||||
workUnit.Label = string.IsNullOrWhiteSpace(workUnit.Label) ? "Work unit" : workUnit.Label.Trim();
|
||||
workUnit.ManualWorkedHours = Math.Max(0m, workUnit.ManualWorkedHours);
|
||||
workUnit.CoeffSnapshot = new CoeffSnapshotDocument
|
||||
{
|
||||
StandardWorkHoursPerDay = settings.StandardWorkHoursPerDay,
|
||||
LunchBreakHours = settings.LunchBreakHours,
|
||||
HourlyGrossRate = settings.HourlyGrossRate,
|
||||
ProfitabilityCoefficient = settings.ProfitabilityCoefficient,
|
||||
InpsRate = settings.InpsRate,
|
||||
SubstituteTaxRate = settings.SubstituteTaxRate
|
||||
};
|
||||
workUnit.CreatedAtUtc = existingCreatedAt;
|
||||
workUnit.UpdatedAtUtc = now;
|
||||
|
||||
Compute(workDay);
|
||||
Compute(workUnit);
|
||||
|
||||
// Preserve creation timestamp for existing documents
|
||||
var existing = workDaysCollection.GetDocument(workDay.Id);
|
||||
if (existing is not null)
|
||||
if (existingIndex >= 0)
|
||||
{
|
||||
workDay.CreatedAtUtc = ReadDateTimeOffset(existing, "createdAtUtc");
|
||||
day.WorkUnits[existingIndex] = workUnit;
|
||||
}
|
||||
else
|
||||
{
|
||||
workDay.CreatedAtUtc = DateTimeOffset.UtcNow;
|
||||
day.WorkUnits.Add(workUnit);
|
||||
}
|
||||
|
||||
workDay.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
day.UpdatedAtUtc = now;
|
||||
SortEntries(day);
|
||||
SaveDocument(day);
|
||||
return workUnit;
|
||||
}
|
||||
|
||||
SaveDocument(workDay);
|
||||
return workDay;
|
||||
public async Task<CalendarEventDocument> SaveCalendarEventAsync(DateOnly date, CalendarEventDocument calendarEvent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var day = await GetOrCreateDayAsync(date, cancellationToken);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var existingIndex = day.CalendarEvents.FindIndex(entry => string.Equals(entry.Id, calendarEvent.Id, StringComparison.Ordinal));
|
||||
var existingCreatedAt = existingIndex >= 0 ? day.CalendarEvents[existingIndex].CreatedAtUtc : now;
|
||||
|
||||
calendarEvent.Id = string.IsNullOrWhiteSpace(calendarEvent.Id) ? Guid.NewGuid().ToString("N") : calendarEvent.Id;
|
||||
calendarEvent.Description = string.IsNullOrWhiteSpace(calendarEvent.Description)
|
||||
? "Calendar entry"
|
||||
: calendarEvent.Description.Trim();
|
||||
calendarEvent.CreatedAtUtc = existingCreatedAt;
|
||||
calendarEvent.UpdatedAtUtc = now;
|
||||
|
||||
Compute(calendarEvent);
|
||||
|
||||
if (existingIndex >= 0)
|
||||
{
|
||||
day.CalendarEvents[existingIndex] = calendarEvent;
|
||||
}
|
||||
else
|
||||
{
|
||||
day.CalendarEvents.Add(calendarEvent);
|
||||
}
|
||||
|
||||
day.UpdatedAtUtc = now;
|
||||
SortEntries(day);
|
||||
SaveDocument(day);
|
||||
return calendarEvent;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteWorkUnitAsync(DateOnly date, string workUnitId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var day = await GetAsync(date, cancellationToken);
|
||||
if (day is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var removed = day.WorkUnits.RemoveAll(unit => string.Equals(unit.Id, workUnitId, StringComparison.Ordinal));
|
||||
if (removed == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return DeleteOrSaveDay(day);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteCalendarEventAsync(DateOnly date, string calendarEventId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var day = await GetAsync(date, cancellationToken);
|
||||
if (day is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var removed = day.CalendarEvents.RemoveAll(calendarEvent => string.Equals(calendarEvent.Id, calendarEventId, StringComparison.Ordinal));
|
||||
if (removed == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return DeleteOrSaveDay(day);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<WorkDayDocument>> GetRangeAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
|
||||
|
|
@ -90,88 +177,131 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
|||
return Task.FromResult<IReadOnlyList<WorkDayDocument>>(results);
|
||||
}
|
||||
|
||||
public async Task<MonthlySummaryModel> GetMonthlySummaryAsync(int year, int month, CancellationToken cancellationToken = default)
|
||||
public async Task<MonthlySummaryModel> GetMonthlySummaryAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var from = new DateOnly(year, month, 1);
|
||||
var to = from.AddMonths(1).AddDays(-1);
|
||||
var days = await GetRangeAsync(from, to, cancellationToken);
|
||||
|
||||
var includedUnits = days
|
||||
.SelectMany(day => day.WorkUnits.Where(unit => includePreview || !unit.IsPreview).Select(unit => new { day.Date, Unit = unit }))
|
||||
.ToList();
|
||||
|
||||
var previewUnits = days
|
||||
.SelectMany(day => day.WorkUnits.Where(unit => unit.IsPreview).Select(unit => new { day.Date, Unit = unit }))
|
||||
.ToList();
|
||||
|
||||
return new MonthlySummaryModel
|
||||
{
|
||||
Year = year,
|
||||
Month = month,
|
||||
TotalWorkedHours = days.Sum(d => d.WorkedHoursFinal),
|
||||
OfficeDays = days.Count(d => d.DayType == DayType.Work),
|
||||
HomeDays = days.Count(d => d.DayType == DayType.Home),
|
||||
HolidayDays = days.Count(d => d.DayType == DayType.Holiday),
|
||||
SickDays = days.Count(d => d.DayType == DayType.Illness),
|
||||
DaysOff = days.Count(d => d.DayType == DayType.DayOff),
|
||||
ClosureDays = days.Count(d => d.DayType == DayType.Closure),
|
||||
TotalHoursOff = days.Sum(d => d.HoursOff),
|
||||
TotalGrossIncome = days.Sum(d => d.GrossIncome),
|
||||
TotalNetIncome = days.Sum(d => d.NetIncome),
|
||||
TotalWorkingDays = days.Count(d => d.DayType is DayType.Work or DayType.Home)
|
||||
TotalWorkedHours = includedUnits.Sum(item => item.Unit.ManualWorkedHours),
|
||||
TotalPreviewWorkedHours = previewUnits.Sum(item => item.Unit.ManualWorkedHours),
|
||||
CountedWorkUnits = includedUnits.Count,
|
||||
PreviewWorkUnits = previewUnits.Count,
|
||||
OfficeDays = includedUnits.Where(item => item.Unit.Location == WorkUnitLocation.Office).Select(item => item.Date).Distinct().Count(),
|
||||
HomeDays = includedUnits.Where(item => item.Unit.Location == WorkUnitLocation.Home).Select(item => item.Date).Distinct().Count(),
|
||||
HolidayDays = CountDaysWithEvent(days, CalendarEventType.Holiday),
|
||||
SickDays = CountDaysWithEvent(days, CalendarEventType.Illness),
|
||||
DaysOff = CountDaysWithEvent(days, CalendarEventType.DayOff),
|
||||
ClosureDays = CountDaysWithEvent(days, CalendarEventType.Closure),
|
||||
TotalHoursOff = days.Sum(day => GetHoursOff(day, includePreview)),
|
||||
TotalGrossIncome = includedUnits.Sum(item => item.Unit.GrossIncome),
|
||||
TotalNetIncome = includedUnits.Sum(item => item.Unit.NetIncome),
|
||||
TotalWorkingDays = includedUnits.Select(item => item.Date).Distinct().Count()
|
||||
};
|
||||
}
|
||||
|
||||
private static void Compute(WorkDayDocument day)
|
||||
public async Task<int> GenerateMonthlyPreviewWorkUnitsAsync(int year, int month, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var coeff = day.CoeffSnapshot;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Calculate projected exit time
|
||||
if (day.StartTime.HasValue && day.DayType is DayType.Work or DayType.Home)
|
||||
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;
|
||||
|
||||
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;
|
||||
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++;
|
||||
}
|
||||
|
||||
// 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);
|
||||
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 +309,311 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
|||
workDaysCollection.Save(doc);
|
||||
}
|
||||
|
||||
private bool DeleteOrSaveDay(WorkDayDocument day)
|
||||
{
|
||||
if (day.WorkUnits.Count == 0 && day.CalendarEvents.Count == 0)
|
||||
{
|
||||
var existing = workDaysCollection.GetDocument(day.Id);
|
||||
if (existing is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
workDaysCollection.Delete(existing);
|
||||
return true;
|
||||
}
|
||||
|
||||
day.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
SortEntries(day);
|
||||
SaveDocument(day);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static WorkDayDocument Map(Document doc)
|
||||
{
|
||||
if (!doc.Contains("workUnits") && !doc.Contains("calendarEvents"))
|
||||
{
|
||||
return MapLegacy(doc);
|
||||
}
|
||||
|
||||
var workUnits = new List<WorkUnitDocument>();
|
||||
var workUnitsArray = doc.GetArray("workUnits");
|
||||
if (workUnitsArray is not null)
|
||||
{
|
||||
for (var i = 0; i < workUnitsArray.Count; i++)
|
||||
{
|
||||
var unit = workUnitsArray.GetDictionary(i);
|
||||
if (unit is not null)
|
||||
{
|
||||
workUnits.Add(MapWorkUnit(unit));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var calendarEvents = new List<CalendarEventDocument>();
|
||||
var calendarEventsArray = doc.GetArray("calendarEvents");
|
||||
if (calendarEventsArray is not null)
|
||||
{
|
||||
for (var i = 0; i < calendarEventsArray.Count; i++)
|
||||
{
|
||||
var calendarEvent = calendarEventsArray.GetDictionary(i);
|
||||
if (calendarEvent is not null)
|
||||
{
|
||||
calendarEvents.Add(MapCalendarEvent(calendarEvent));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new WorkDayDocument
|
||||
{
|
||||
Id = doc.Id,
|
||||
Date = DateOnly.ParseExact(doc.GetString("date") ?? doc.Id, "yyyy-MM-dd"),
|
||||
StartTime = ReadTimeOnly(doc, "startTime"),
|
||||
ProjectedExitTime = ReadTimeOnly(doc, "projectedExitTime"),
|
||||
ActualExitTime = ReadTimeOnly(doc, "actualExitTime"),
|
||||
DayType = (DayType)doc.GetInt("dayType"),
|
||||
ExtraHoursDelta = Convert.ToDecimal(doc.GetDouble("extraHoursDelta")),
|
||||
WorkedHoursBase = Convert.ToDecimal(doc.GetDouble("workedHoursBase")),
|
||||
WorkedHoursFinal = Convert.ToDecimal(doc.GetDouble("workedHoursFinal")),
|
||||
HoursOff = Convert.ToDecimal(doc.GetDouble("hoursOff")),
|
||||
GrossIncome = Convert.ToDecimal(doc.GetDouble("grossIncome")),
|
||||
NetIncome = Convert.ToDecimal(doc.GetDouble("netIncome")),
|
||||
IsWeekend = doc.GetBoolean("isWeekend"),
|
||||
IsItalianFestivity = doc.GetBoolean("isItalianFestivity"),
|
||||
Notes = doc.GetString("notes"),
|
||||
CoeffSnapshot = new CoeffSnapshotDocument
|
||||
{
|
||||
StandardWorkHoursPerDay = ReadDecimal(doc, "coeff_standardWorkHoursPerDay", 8m),
|
||||
LunchBreakHours = ReadDecimal(doc, "coeff_lunchBreakHours", 1m),
|
||||
HourlyGrossRate = ReadDecimal(doc, "coeff_hourlyGrossRate", 17.5m),
|
||||
ProfitabilityCoefficient = ReadDecimal(doc, "coeff_profitabilityCoefficient", 0.67m),
|
||||
InpsRate = ReadDecimal(doc, "coeff_inpsRate", 0.2607m),
|
||||
SubstituteTaxRate = ReadDecimal(doc, "coeff_substituteTaxRate", 0.15m)
|
||||
},
|
||||
WorkUnits = workUnits,
|
||||
CalendarEvents = calendarEvents,
|
||||
CreatedAtUtc = ReadDateTimeOffset(doc, "createdAtUtc"),
|
||||
UpdatedAtUtc = ReadDateTimeOffset(doc, "updatedAtUtc")
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<WorkDayDocument> GetOrCreateDayAsync(DateOnly date, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await GetAsync(date, cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.IsWeekend = date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
|
||||
existing.IsItalianFestivity = festivitySource.GetFestivities(date.Year).Contains(date);
|
||||
existing.Id = date.ToString("yyyy-MM-dd");
|
||||
existing.Date = date;
|
||||
return existing;
|
||||
}
|
||||
|
||||
return new WorkDayDocument
|
||||
{
|
||||
Id = date.ToString("yyyy-MM-dd"),
|
||||
Date = date,
|
||||
IsWeekend = date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday,
|
||||
IsItalianFestivity = festivitySource.GetFestivities(date.Year).Contains(date),
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||
UpdatedAtUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static void Compute(WorkUnitDocument unit)
|
||||
{
|
||||
unit.CalculatedWorkedHours = CalculateDuration(unit.StartTime, unit.EndTime) ?? 0m;
|
||||
unit.WorkedHoursDelta = unit.ManualWorkedHours - unit.CalculatedWorkedHours;
|
||||
|
||||
var coeff = unit.CoeffSnapshot;
|
||||
unit.GrossIncome = unit.ManualWorkedHours * coeff.HourlyGrossRate;
|
||||
var taxableBase = unit.GrossIncome * coeff.ProfitabilityCoefficient;
|
||||
unit.NetIncome = unit.GrossIncome - (taxableBase * coeff.InpsRate) - (taxableBase * coeff.SubstituteTaxRate);
|
||||
}
|
||||
|
||||
private static void Compute(CalendarEventDocument calendarEvent)
|
||||
{
|
||||
calendarEvent.DurationHours = CalculateDuration(calendarEvent.StartTime, calendarEvent.EndTime);
|
||||
}
|
||||
|
||||
private static decimal? CalculateDuration(TimeOnly? startTime, TimeOnly? endTime)
|
||||
{
|
||||
if (!startTime.HasValue || !endTime.HasValue || endTime <= startTime)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.Round((decimal)(endTime.Value - startTime.Value).TotalHours, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private static int CountDaysWithEvent(IEnumerable<WorkDayDocument> days, CalendarEventType eventType)
|
||||
{
|
||||
return days.Count(day => day.CalendarEvents.Any(calendarEvent => calendarEvent.EventType == eventType));
|
||||
}
|
||||
|
||||
private static 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 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 +622,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 +637,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 +651,12 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
|||
? dt
|
||||
: DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
private static DateTimeOffset ReadDateTimeOffset(DictionaryObject doc, string key)
|
||||
{
|
||||
var value = doc.GetString(key);
|
||||
return !string.IsNullOrEmpty(value) && DateTimeOffset.TryParse(value, out var dt)
|
||||
? dt
|
||||
: DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,21 @@ public interface IWorkDayService
|
|||
{
|
||||
Task<WorkDayDocument?> GetAsync(DateOnly date, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<WorkDayDocument> SaveAsync(WorkDayDocument workDay, CancellationToken cancellationToken = default);
|
||||
Task<WorkUnitDocument?> GetWorkUnitAsync(DateOnly date, string workUnitId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CalendarEventDocument?> GetCalendarEventAsync(DateOnly date, string calendarEventId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<WorkUnitDocument> SaveWorkUnitAsync(DateOnly date, WorkUnitDocument workUnit, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CalendarEventDocument> SaveCalendarEventAsync(DateOnly date, CalendarEventDocument calendarEvent, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> DeleteWorkUnitAsync(DateOnly date, string workUnitId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> DeleteCalendarEventAsync(DateOnly date, string calendarEventId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<WorkDayDocument>> GetRangeAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<MonthlySummaryModel> GetMonthlySummaryAsync(int year, int month, CancellationToken cancellationToken = default);
|
||||
Task<MonthlySummaryModel> GetMonthlySummaryAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> GenerateMonthlyPreviewWorkUnitsAsync(int year, int month, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue