411 lines
15 KiB
Text
411 lines
15 KiB
Text
|
|
@inject IWorkDayService WorkDayService
|
||
|
|
@inject IAppSettingsService AppSettingsService
|
||
|
|
@inject IJSRuntime JS
|
||
|
|
|
||
|
|
<div class="calendar-modal-backdrop">
|
||
|
|
<div class="calendar-modal-shell">
|
||
|
|
<div class="calendar-modal-dialog" role="dialog" aria-modal="true" aria-labelledby="work-unit-modal-title" @onclick:stopPropagation="true">
|
||
|
|
<div class="calendar-modal-header">
|
||
|
|
<div>
|
||
|
|
<h2 id="work-unit-modal-title" class="h5 mb-1">@(isExistingUnit ? "Edit Work Unit" : "New Work Unit")</h2>
|
||
|
|
<div class="small text-muted">@FormatDisplayDate(selectedDate)</div>
|
||
|
|
</div>
|
||
|
|
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseAsync"></button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="calendar-modal-body">
|
||
|
|
@if (!loaded)
|
||
|
|
{
|
||
|
|
<p class="mb-0"><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>
|
||
|
|
<LocalizedDateInput InputId="work-unit-modal-date" TestId="work-unit-modal-date" Value="@selectedDate" ValueChanged="OnDateChangedAsync" 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" lang="it-IT" 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" lang="it-IT" 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-modal" type="checkbox" class="form-check-input" @bind="isPreview" />
|
||
|
|
<label class="form-check-label" for="preview-checkbox-modal">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 />
|
||
|
|
|
||
|
|
<h3 class="h6">Computed values</h3>
|
||
|
|
<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">
|
||
|
|
<h3 class="h6">Day Total</h3>
|
||
|
|
<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>
|
||
|
|
|
||
|
|
@if (!string.IsNullOrWhiteSpace(statusMessage))
|
||
|
|
{
|
||
|
|
<div class="alert alert-danger py-2 mt-3 mb-0">@statusMessage</div>
|
||
|
|
}
|
||
|
|
}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="calendar-modal-actions">
|
||
|
|
<button type="button" class="btn btn-primary" @onclick="SaveAsync" disabled="@(!loaded)">Save</button>
|
||
|
|
@if (isExistingUnit)
|
||
|
|
{
|
||
|
|
<button type="button" class="btn btn-outline-danger" @onclick="DeleteAsync">Delete</button>
|
||
|
|
}
|
||
|
|
<button type="button" class="btn btn-outline-secondary ms-auto" @onclick="CloseAsync">Cancel</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
@code {
|
||
|
|
[Parameter] public DateOnly Date { get; set; }
|
||
|
|
[Parameter] public string? UnitId { get; set; }
|
||
|
|
[Parameter] public EventCallback OnSaved { get; set; }
|
||
|
|
[Parameter] public EventCallback OnClosed { get; set; }
|
||
|
|
|
||
|
|
private bool loaded;
|
||
|
|
private DateOnly selectedDate;
|
||
|
|
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 string? loadKey;
|
||
|
|
|
||
|
|
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 OnParametersSetAsync()
|
||
|
|
{
|
||
|
|
var nextKey = $"{Date:yyyy-MM-dd}|{UnitId}";
|
||
|
|
if (nextKey == loadKey)
|
||
|
|
{
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
loadKey = nextKey;
|
||
|
|
loaded = false;
|
||
|
|
selectedDate = Date;
|
||
|
|
statusMessage = null;
|
||
|
|
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 OnDateChangedAsync(DateOnly? value)
|
||
|
|
{
|
||
|
|
if (value.HasValue)
|
||
|
|
{
|
||
|
|
selectedDate = value.Value;
|
||
|
|
selectedDay = await WorkDayService.GetAsync(selectedDate);
|
||
|
|
statusMessage = null;
|
||
|
|
RecomputePreview();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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
|
||
|
|
};
|
||
|
|
|
||
|
|
await WorkDayService.SaveWorkUnitAsync(selectedDate, workUnit);
|
||
|
|
await OnSaved.InvokeAsync();
|
||
|
|
}
|
||
|
|
|
||
|
|
private async Task DeleteAsync()
|
||
|
|
{
|
||
|
|
if (!isExistingUnit || string.IsNullOrWhiteSpace(unitId))
|
||
|
|
{
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
var confirmed = await JS.InvokeAsync<bool>("confirm", $"Delete work unit '{label}' on {FormatDisplayDate(selectedDate)}?\nThis cannot be undone.");
|
||
|
|
if (!confirmed)
|
||
|
|
{
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
var deleted = await WorkDayService.DeleteWorkUnitAsync(selectedDate, unitId);
|
||
|
|
if (deleted)
|
||
|
|
{
|
||
|
|
await OnSaved.InvokeAsync();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
statusMessage = "Unable to delete the work unit.";
|
||
|
|
}
|
||
|
|
|
||
|
|
private Task CloseAsync() => OnClosed.InvokeAsync();
|
||
|
|
|
||
|
|
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 string FormatDurationHours(decimal value)
|
||
|
|
{
|
||
|
|
var totalMinutes = (int)Math.Round(value * 60m, MidpointRounding.AwayFromZero);
|
||
|
|
var hours = totalMinutes / 60;
|
||
|
|
var minutes = totalMinutes % 60;
|
||
|
|
return $"{hours:00}:{minutes:00}";
|
||
|
|
}
|
||
|
|
|
||
|
|
private static string FormatHours(decimal? value)
|
||
|
|
{
|
||
|
|
return value.HasValue ? DurationFormatter.FormatHours(value.Value) : "—";
|
||
|
|
}
|
||
|
|
|
||
|
|
private static string FormatSignedHours(decimal value)
|
||
|
|
{
|
||
|
|
if (value == 0m)
|
||
|
|
{
|
||
|
|
return "00:00";
|
||
|
|
}
|
||
|
|
|
||
|
|
var prefix = value > 0m ? "+" : "-";
|
||
|
|
return prefix + DurationFormatter.FormatHours(Math.Abs(value));
|
||
|
|
}
|
||
|
|
|
||
|
|
private static bool TryParseDurationHours(string? value, out decimal hours)
|
||
|
|
{
|
||
|
|
hours = 0m;
|
||
|
|
if (string.IsNullOrWhiteSpace(value))
|
||
|
|
{
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (TimeOnly.TryParse(value, out var parsedTime))
|
||
|
|
{
|
||
|
|
hours = parsedTime.Hour + (parsedTime.Minute / 60m);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (decimal.TryParse(value, out var parsedDecimal))
|
||
|
|
{
|
||
|
|
hours = Math.Max(0m, parsedDecimal);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static string FormatDisplayDate(DateOnly date)
|
||
|
|
{
|
||
|
|
return date.ToString("dddd dd/MM/yyyy");
|
||
|
|
}
|
||
|
|
}
|