@page "/work-unit"
@page "/work-unit/{DateStr}"
@page "/work-unit/{DateStr}/{UnitId}"
@attribute [Authorize]
@rendermode InteractiveServer
@inject IWorkDayService WorkDayService
@inject IAppSettingsService AppSettingsService
@inject NavigationManager Navigation
@inject IJSRuntime JS
Work Unit
Work Unit
@if (!loaded)
{
Loading...
}
else
{
Computed values
@FormatHours(calculatedWorkedHours)
@FormatSignedHours(workedHoursDelta)
€@grossIncome.ToString("N2")
€@netIncome.ToString("N2")
Day Total
@FormatHours(dayTotalHours)
@dayWorkUnitCount
@if (isExistingUnit)
{
}
@if (!string.IsNullOrWhiteSpace(statusMessage))
{
@statusMessage
}
}
@code {
[Parameter] public string? DateStr { get; set; }
[Parameter] public string? UnitId { get; set; }
private bool loaded;
private DateOnly selectedDate = DateOnly.FromDateTime(DateTime.Today);
private string unitId = string.Empty;
private string label = "Work unit";
private WorkUnitLocation location = WorkUnitLocation.Office;
private string? startTimeStr;
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;
private decimal grossIncome;
private decimal netIncome;
private decimal? calculatedWorkedHours;
private decimal workedHoursDelta;
private decimal dayTotalHours;
private int dayWorkUnitCount;
private AppSettingsDocument settings = new();
protected override async Task OnInitializedAsync()
{
if (!string.IsNullOrEmpty(DateStr) && DateOnly.TryParseExact(DateStr, "yyyy-MM-dd", out var parsed))
{
selectedDate = parsed;
}
settings = await AppSettingsService.GetAsync();
await LoadUnitAsync();
loaded = true;
}
private async Task LoadUnitAsync()
{
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)
{
unitId = existing.Id;
label = existing.Label;
location = existing.Location;
startTimeStr = existing.StartTime?.ToString("HH:mm");
endTimeStr = existing.EndTime?.ToString("HH:mm");
manualWorkedHours = existing.ManualWorkedHours;
manualWorkedHoursStr = FormatDurationHours(existing.ManualWorkedHours);
isPreview = existing.IsPreview;
notes = existing.Notes;
isExistingUnit = true;
}
else
{
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)
{
if (DateOnly.TryParse(e.Value?.ToString(), out var d))
{
selectedDate = d;
statusMessage = null;
}
}
private Task OnStartTimeChanged(ChangeEventArgs e)
{
startTimeStr = e.Value?.ToString();
SyncManualHoursToCalculated();
statusMessage = null;
return Task.CompletedTask;
}
private Task OnEndTimeChanged(ChangeEventArgs e)
{
endTimeStr = e.Value?.ToString();
SyncManualHoursToCalculated();
statusMessage = null;
return Task.CompletedTask;
}
private Task OnManualWorkedHoursChanged(ChangeEventArgs e)
{
var rawValue = e.Value?.ToString();
if (TryParseDurationHours(rawValue, out var parsedHours))
{
manualWorkedHours = parsedHours;
manualWorkedHoursStr = FormatDurationHours(parsedHours);
}
else
{
manualWorkedHoursStr = FormatDurationHours(manualWorkedHours);
}
RecomputePreview();
statusMessage = null;
return Task.CompletedTask;
}
private void SetDefaults()
{
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()
{
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()
{
RecomputePreview();
var workUnit = new WorkUnitDocument
{
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.SaveWorkUnitAsync(selectedDate, workUnit);
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;
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();
}
}