WorkTracker/Components/Pages/WorkDayEditor.razor
Marco cab549ab3a 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
2026-04-20 16:11:27 +02:00

409 lines
14 KiB
Text

@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
<PageTitle>Work Unit</PageTitle>
<h1>Work Unit</h1>
@if (!loaded)
{
<p><em>Loading...</em></p>
}
else
{
<div class="row g-3">
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">Date</label>
<input type="date" class="form-control" value="@selectedDate.ToString("yyyy-MM-dd")" @onchange="OnDateChanged" disabled="@isExistingUnit" />
</div>
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">Label</label>
<input class="form-control" @bind="label" maxlength="40" />
</div>
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">Location</label>
<select class="form-select" @bind="location">
@foreach (var item in Enum.GetValues<WorkUnitLocation>())
{
<option value="@item">@item</option>
}
</select>
</div>
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">Start Time</label>
<input type="time" class="form-control" value="@startTimeStr" @onchange="OnStartTimeChanged" />
</div>
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">End Time</label>
<input type="time" class="form-control" value="@endTimeStr" @onchange="OnEndTimeChanged" />
</div>
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">Counted Hours</label>
<input type="text" class="form-control" value="@manualWorkedHoursStr" @onchange="OnManualWorkedHoursChanged" placeholder="00:00" inputmode="numeric" />
</div>
<div class="col-12 col-md-6 col-lg-4 d-flex align-items-end">
<div class="form-check mb-2">
<input id="preview-checkbox" type="checkbox" class="form-check-input" @bind="isPreview" />
<label class="form-check-label" for="preview-checkbox">Preview work unit</label>
</div>
</div>
<div class="col-12">
<label class="form-label">Notes</label>
<textarea class="form-control" rows="2" @bind="notes"></textarea>
</div>
</div>
<hr />
<h2 class="h5">Computed values</h2>
<div class="row g-3">
<div class="col-6 col-md-4 col-lg-3">
<label class="form-label text-muted">Calculated Hours</label>
<div class="form-control-plaintext fw-bold">@FormatHours(calculatedWorkedHours)</div>
</div>
<div class="col-6 col-md-4 col-lg-3">
<label class="form-label text-muted">Difference</label>
<div class="form-control-plaintext fw-bold">@FormatSignedHours(workedHoursDelta)</div>
</div>
<div class="col-6 col-md-4 col-lg-3">
<label class="form-label text-muted">Gross Income</label>
<div class="form-control-plaintext fw-bold">€@grossIncome.ToString("N2")</div>
</div>
<div class="col-6 col-md-4 col-lg-3">
<label class="form-label text-muted">Net Income</label>
<div class="form-control-plaintext fw-bold">€@netIncome.ToString("N2")</div>
</div>
</div>
<div class="mt-4">
<h2 class="h5">Day Total</h2>
<div class="row g-3">
<div class="col-6 col-md-4 col-lg-3">
<label class="form-label text-muted">Total Hours For Day</label>
<div class="form-control-plaintext fw-bold">@FormatHours(dayTotalHours)</div>
</div>
<div class="col-6 col-md-4 col-lg-3">
<label class="form-label text-muted">Work Units Counted</label>
<div class="form-control-plaintext fw-bold">@dayWorkUnitCount</div>
</div>
</div>
</div>
<div class="d-flex align-items-center gap-2 mt-4">
<button class="btn btn-primary" @onclick="SaveAsync">Save</button>
@if (isExistingUnit)
{
<button class="btn btn-outline-danger" @onclick="DeleteAsync">Delete</button>
}
<button class="btn btn-outline-secondary" @onclick="BackToCalendar">Back to Calendar</button>
@if (!string.IsNullOrWhiteSpace(statusMessage))
{
<span class="text-success">@statusMessage</span>
}
</div>
}
@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<bool>("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();
}
}