@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(); } }