595 lines
30 KiB
Plaintext
595 lines
30 KiB
Plaintext
@page "/"
|
||
@rendermode InteractiveWebAssembly
|
||
@attribute [Authorize]
|
||
@inject ITimetrackerService TrackerService
|
||
@inject IHolidayService HolidayService
|
||
@inject ISnackbar Snackbar
|
||
@inject AuthenticationStateProvider AuthStateProvider
|
||
@inject IJSRuntime JSRuntime
|
||
|
||
<PageTitle>KW @_kw – Wochenübersicht – Timetracker</PageTitle>
|
||
|
||
@if (_loading)
|
||
{
|
||
<MudStack AlignItems="AlignItems.Center" Class="mt-16" Spacing="3">
|
||
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
|
||
<MudText Color="Color.Secondary">Lade Wochendaten…</MudText>
|
||
</MudStack>
|
||
}
|
||
else
|
||
{
|
||
<MudStack Spacing="3">
|
||
|
||
@* ── Wochen-Header ── *@
|
||
<MudPaper Elevation="4" Class="pa-5 rounded-xl onboarding-week-header"
|
||
Style="background: #1E293B; color: white;">
|
||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
|
||
Style="color:white" Size="Size.Large" OnClick="PrevWeek" />
|
||
<MudStack Spacing="0" AlignItems="AlignItems.Center">
|
||
<MudText Typo="Typo.h5" Style="color:white; font-weight:700; letter-spacing:0.5px">
|
||
@_weekLabel
|
||
</MudText>
|
||
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
|
||
@_weekSubLabel
|
||
</MudText>
|
||
</MudStack>
|
||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
|
||
@if (!IsCurrentWeek)
|
||
{
|
||
<MudButton Variant="Variant.Text" Style="color:white"
|
||
OnClick="GoToCurrentWeek" Size="Size.Small">
|
||
Heute
|
||
</MudButton>
|
||
}
|
||
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
|
||
Style="color:white" Size="Size.Large" OnClick="NextWeek" />
|
||
</MudStack>
|
||
</MudStack>
|
||
</MudPaper>
|
||
|
||
@* ── Tageskarten ── *@
|
||
@foreach (var day in _days)
|
||
{
|
||
var isToday = day.Date == DateOnly.FromDateTime(DateTime.Today);
|
||
var isWorkDay = _settings.IsWorkDay(day.Date.DayOfWeek);
|
||
var hasData = day.Start.HasValue || day.End.HasValue;
|
||
var overtime = GetOvertime(day);
|
||
var errors = GetDayErrors(day).ToList();
|
||
var borderColor = GetBorderColor(day, isWorkDay, errors);
|
||
var progressPct = GetProgressPercent(day);
|
||
var holidayName = _holidays.GetValueOrDefault(day.Date);
|
||
|
||
if (!isWorkDay)
|
||
{
|
||
@* ── Nicht-Arbeitstag: kompakt ── *@
|
||
<MudPaper @key="@day.Date" Elevation="1" Class="pa-3 rounded-lg"
|
||
Style="@($"border-left: 4px solid {(!string.IsNullOrEmpty(holidayName) ? "#009688" : "#CFD8DC")}; background:{(!string.IsNullOrEmpty(holidayName) ? "rgba(0,150,136,0.05)" : "#FAFAFA")};")">
|
||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||
<MudStack Spacing="0">
|
||
<MudText Typo="Typo.body1" Style="color:#90A4AE; font-weight:500">
|
||
@day.Date.ToString("dddd", _deCulture)
|
||
</MudText>
|
||
<MudText Typo="Typo.caption" Style="color:#B0BEC5">
|
||
@day.Date.ToString("dd. MMMM yyyy", _deCulture)
|
||
</MudText>
|
||
</MudStack>
|
||
@if (!string.IsNullOrEmpty(holidayName))
|
||
{
|
||
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled"
|
||
Style="background:#009688; color:white; font-weight:600">
|
||
@holidayName
|
||
</MudChip>
|
||
}
|
||
else
|
||
{
|
||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined"
|
||
Style="color:#90A4AE; border-color:#CFD8DC;">
|
||
Kein Arbeitstag
|
||
</MudChip>
|
||
}
|
||
</MudStack>
|
||
</MudPaper>
|
||
}
|
||
else
|
||
{
|
||
@* ── Arbeitstag: vollständige Karte ── *@
|
||
<MudCard @key="@day.Date" Elevation="@(isToday ? 6 : 2)" Class="rounded-xl onboarding-day-card"
|
||
Style="@($"border-left: 4px solid {borderColor};")">
|
||
<MudCardHeader Style="@(isToday ? "background: linear-gradient(90deg, rgba(14,165,233,0.07) 0%, transparent 100%);" : "")">
|
||
<CardHeaderContent>
|
||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||
<MudStack Spacing="0">
|
||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||
<MudText Typo="Typo.h6" Style="font-weight:600">
|
||
@day.Date.ToString("dddd", _deCulture)
|
||
</MudText>
|
||
@if (isToday)
|
||
{
|
||
<MudChip T="string" Size="Size.Small" Color="Color.Primary"
|
||
Variant="Variant.Filled"
|
||
Style="height:20px; font-size:10px; font-weight:700">
|
||
HEUTE
|
||
</MudChip>
|
||
}
|
||
@if (!string.IsNullOrEmpty(holidayName))
|
||
{
|
||
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled"
|
||
Style="height:20px; font-size:10px; font-weight:700; background:#009688; color:white;">
|
||
@holidayName
|
||
</MudChip>
|
||
}
|
||
</MudStack>
|
||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||
@day.Date.ToString("dd. MMMM yyyy", _deCulture)
|
||
</MudText>
|
||
</MudStack>
|
||
|
||
@if (overtime.HasValue)
|
||
{
|
||
<MudChip T="string"
|
||
Color="@(overtime.Value > TimeSpan.Zero ? Color.Success : overtime.Value < TimeSpan.Zero ? Color.Warning : Color.Info)"
|
||
Variant="Variant.Filled" Size="Size.Medium">
|
||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||
<MudIcon Icon="@(overtime.Value >= TimeSpan.Zero ? Icons.Material.Filled.TrendingUp : Icons.Material.Filled.TrendingDown)"
|
||
Size="Size.Small" />
|
||
<MudText Typo="Typo.body2"><b>@FormatTs(overtime.Value, sign: true)</b></MudText>
|
||
</MudStack>
|
||
</MudChip>
|
||
}
|
||
else if (!hasData)
|
||
{
|
||
<MudChip T="string" Color="Color.Default" Variant="Variant.Outlined" Size="Size.Small">
|
||
Nicht erfasst
|
||
</MudChip>
|
||
}
|
||
</MudStack>
|
||
</CardHeaderContent>
|
||
</MudCardHeader>
|
||
|
||
<MudCardContent Class="pt-2">
|
||
@* ── Zeit-Eingaben + Statistik ── *@
|
||
<MudGrid Spacing="3">
|
||
<MudItem xs="6" sm="3" md="2">
|
||
<MudTimePicker Label="Beginn"
|
||
Time="@day.Start"
|
||
TimeChanged="@(async v => await OnStartChanged(day, v))"
|
||
AmPm="false" Variant="Variant.Outlined"
|
||
Clearable="true" PickerVariant="PickerVariant.Dialog"
|
||
Error="@(day.End.HasValue && day.Start.HasValue && day.Start >= day.End)" />
|
||
</MudItem>
|
||
<MudItem xs="6" sm="3" md="2">
|
||
<MudTimePicker Label="Ende"
|
||
Time="@day.End"
|
||
TimeChanged="@(async v => await OnEndChanged(day, v))"
|
||
AmPm="false" Variant="Variant.Outlined"
|
||
Clearable="true" PickerVariant="PickerVariant.Dialog"
|
||
Error="@(day.End.HasValue && day.Start.HasValue && day.Start >= day.End)" />
|
||
</MudItem>
|
||
|
||
@if (day.GrossWork.HasValue)
|
||
{
|
||
<MudItem xs="12" md="8">
|
||
<MudStack Row="true" Spacing="3" AlignItems="AlignItems.Center" Wrap="Wrap.Wrap" Class="h-100 pl-1">
|
||
<MudStack Spacing="0">
|
||
<MudText Typo="Typo.overline" Color="Color.Secondary">Brutto</MudText>
|
||
<MudText Typo="Typo.body1">@FormatTs(day.GrossWork.Value)</MudText>
|
||
</MudStack>
|
||
<MudText Color="Color.Secondary" Class="mb-1">−</MudText>
|
||
<MudStack Spacing="0">
|
||
<MudText Typo="Typo.overline" Color="Color.Secondary">Pausen</MudText>
|
||
<MudText Typo="Typo.body1">@FormatTs(day.TotalBreakTime)</MudText>
|
||
</MudStack>
|
||
<MudText Color="Color.Secondary" Class="mb-1">=</MudText>
|
||
<MudStack Spacing="0">
|
||
<MudText Typo="Typo.overline" Color="Color.Secondary">Netto</MudText>
|
||
<MudText Typo="Typo.body1" Color="Color.Primary" Style="font-weight:700">
|
||
@FormatTs(day.NetWork!.Value)
|
||
</MudText>
|
||
</MudStack>
|
||
<MudDivider Vertical="true" FlexItem="true" Style="height:36px" />
|
||
<MudStack Spacing="0">
|
||
<MudText Typo="Typo.overline" Color="Color.Secondary">Soll</MudText>
|
||
<MudText Typo="Typo.body1">@FormatTs(TimeSpan.FromHours(_settings.DailyTargetHours))</MudText>
|
||
</MudStack>
|
||
</MudStack>
|
||
</MudItem>
|
||
}
|
||
</MudGrid>
|
||
|
||
@* ── Fortschrittsbalken ── *@
|
||
@if (progressPct >= 0)
|
||
{
|
||
<MudTooltip Text="@($"Netto {FormatTs(day.NetWork ?? TimeSpan.Zero)} von {FormatTs(TimeSpan.FromHours(_settings.DailyTargetHours))} Soll")">
|
||
<MudProgressLinear Value="@Math.Min(progressPct, 100)"
|
||
Color="@(progressPct >= 100 ? Color.Success : Color.Primary)"
|
||
Rounded="true" Class="mt-3" Style="height:6px" />
|
||
</MudTooltip>
|
||
}
|
||
|
||
@* ── Pausen-Sektion ── *@
|
||
<MudDivider Class="mt-3 mb-2" />
|
||
<MudStack Spacing="2">
|
||
@for (int i = 0; i < day.Breaks.Count; i++)
|
||
{
|
||
var brk = day.Breaks[i];
|
||
var idx = i;
|
||
var brkError = (day.Start.HasValue && brk.Start.HasValue && brk.Start < day.Start)
|
||
|| (day.End.HasValue && brk.End.HasValue && brk.End > day.End);
|
||
|
||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Wrap="Wrap.Wrap">
|
||
<MudIcon Icon="@Icons.Material.Filled.FreeBreakfast"
|
||
Size="Size.Small" Color="Color.Secondary" Style="opacity:0.6" />
|
||
<MudText Typo="Typo.caption" Color="Color.Secondary" Style="min-width:55px">
|
||
Pause @(idx + 1)
|
||
</MudText>
|
||
<MudTimePicker Time="@brk.Start"
|
||
TimeChanged="@(async v => await OnBreakStartChanged(day, idx, v))"
|
||
AmPm="false" Variant="Variant.Outlined"
|
||
Style="width:300px" Clearable="true"
|
||
PickerVariant="PickerVariant.Inline"
|
||
Error="@brkError" />
|
||
<MudText Color="Color.Secondary">–</MudText>
|
||
<MudTimePicker Time="@brk.End"
|
||
TimeChanged="@(async v => await OnBreakEndChanged(day, idx, v))"
|
||
AmPm="false" Variant="Variant.Outlined"
|
||
Style="width:300px" Clearable="true"
|
||
PickerVariant="PickerVariant.Inline"
|
||
Error="@brkError" />
|
||
@if (brk.Duration.HasValue)
|
||
{
|
||
<MudChip T="string" Size="Size.Small" Variant="Variant.Text" Color="Color.Secondary">
|
||
@FormatTs(brk.Duration.Value)
|
||
</MudChip>
|
||
}
|
||
<MudIconButton Icon="@Icons.Material.Filled.RemoveCircleOutline"
|
||
Size="Size.Small" Color="Color.Error"
|
||
OnClick="@(async () => await RemoveBreak(day, idx))" />
|
||
</MudStack>
|
||
}
|
||
<MudButton StartIcon="@Icons.Material.Filled.AddCircleOutline"
|
||
Variant="Variant.Text" Color="Color.Primary"
|
||
Size="Size.Small" OnClick="@(() => AddBreak(day))">
|
||
Pause hinzufügen
|
||
</MudButton>
|
||
</MudStack>
|
||
|
||
@* ── Validierungsfehler ── *@
|
||
@if (errors.Count > 0)
|
||
{
|
||
<MudDivider Class="mt-2 mb-1" />
|
||
@foreach (var err in errors)
|
||
{
|
||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-1">@err</MudAlert>
|
||
}
|
||
}
|
||
|
||
</MudCardContent>
|
||
</MudCard>
|
||
}
|
||
}
|
||
|
||
@* ── Wochensumme ── *@
|
||
<MudPaper Elevation="4" Class="pa-5 rounded-xl onboarding-week-summary"
|
||
Style="background: #0F172A; color:white;">
|
||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-4">
|
||
<MudIcon Icon="@Icons.Material.Filled.Summarize" Style="color:rgba(255,255,255,0.8)" />
|
||
<MudText Typo="Typo.h6" Style="color:white; font-weight:600">Wochensumme</MudText>
|
||
</MudStack>
|
||
<MudGrid Spacing="3">
|
||
<MudItem xs="6" sm="3">
|
||
<MudStack Spacing="0">
|
||
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Brutto</MudText>
|
||
<MudText Typo="Typo.h5" Style="color:white">@FormatTs(WeekGross)</MudText>
|
||
</MudStack>
|
||
</MudItem>
|
||
<MudItem xs="6" sm="3">
|
||
<MudStack Spacing="0">
|
||
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Pausen</MudText>
|
||
<MudText Typo="Typo.h5" Style="color:white">@FormatTs(WeekBreaks)</MudText>
|
||
</MudStack>
|
||
</MudItem>
|
||
<MudItem xs="6" sm="3">
|
||
<MudStack Spacing="0">
|
||
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Netto</MudText>
|
||
<MudText Typo="Typo.h5" Style="color:#90CAF9; font-weight:700">@FormatTs(WeekNet)</MudText>
|
||
</MudStack>
|
||
</MudItem>
|
||
<MudItem xs="6" sm="3">
|
||
<MudStack Spacing="0">
|
||
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Gleitzeit</MudText>
|
||
<MudText Typo="Typo.h5" Style="@($"color:{(WeekOvertime >= TimeSpan.Zero ? "#A5D6A7" : "#FFCC80")}; font-weight:700")">
|
||
@FormatTs(WeekOvertime, sign: true)
|
||
</MudText>
|
||
</MudStack>
|
||
</MudItem>
|
||
</MudGrid>
|
||
</MudPaper>
|
||
|
||
@* ── Gleitzeitkonto ── *@
|
||
<MudPaper Elevation="3" Class="pa-5 rounded-xl onboarding-overtime-balance"
|
||
Style="@($"border-left: 6px solid {(_totalOvertime >= TimeSpan.Zero ? "#4CAF50" : "#FF9800")};")">
|
||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Wrap="Wrap.Wrap" Spacing="3">
|
||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||
<MudIcon Icon="@Icons.Material.Filled.AccountBalance" Color="Color.Primary" />
|
||
<MudStack Spacing="0">
|
||
<MudText Typo="Typo.h6" Style="font-weight:600">Gleitzeitkonto</MudText>
|
||
<MudText Typo="Typo.caption" Color="Color.Secondary">Gesamtsaldo aller erfassten Arbeitstage</MudText>
|
||
</MudStack>
|
||
</MudStack>
|
||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||
<MudIcon Icon="@(_totalOvertime >= TimeSpan.Zero ? Icons.Material.Filled.TrendingUp : Icons.Material.Filled.TrendingDown)"
|
||
Style="@($"color:{(_totalOvertime >= TimeSpan.Zero ? "#4CAF50" : "#FF9800")}; font-size:2rem")" />
|
||
<MudText Typo="Typo.h4"
|
||
Style="@($"font-weight:700; color:{(_totalOvertime >= TimeSpan.Zero ? "#4CAF50" : "#FF9800")};")">
|
||
@FormatTs(_totalOvertime, sign: true)
|
||
</MudText>
|
||
</MudStack>
|
||
</MudStack>
|
||
</MudPaper>
|
||
|
||
</MudStack>
|
||
|
||
@if (_showOnboarding)
|
||
{
|
||
<OnboardingTour OnFinished="HandleOnboardingFinished" />
|
||
}
|
||
}
|
||
|
||
@code {
|
||
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
|
||
|
||
private bool _loading = true;
|
||
private int _userId;
|
||
private DateOnly _monday;
|
||
private List<DayVm> _days = [];
|
||
private AppSettings _settings = new();
|
||
private string _weekLabel = "";
|
||
private string _weekSubLabel = "";
|
||
private int _kw => _monday == default ? 0 : _deCulture.Calendar.GetWeekOfYear(
|
||
_monday.ToDateTime(TimeOnly.MinValue),
|
||
System.Globalization.CalendarWeekRule.FirstFourDayWeek,
|
||
DayOfWeek.Monday);
|
||
private TimeSpan _totalOvertime;
|
||
private Dictionary<DateOnly, string> _holidays = [];
|
||
private int _holidayYear = -1;
|
||
private bool _showOnboarding;
|
||
|
||
private bool IsCurrentWeek => _monday == GetMonday(DateOnly.FromDateTime(DateTime.Today));
|
||
|
||
protected override async Task OnInitializedAsync()
|
||
{
|
||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
|
||
if (claim == null) return; // Prerender-Pass – Circuit noch nicht authentifiziert
|
||
_userId = int.Parse(claim.Value);
|
||
_monday = GetMonday(DateOnly.FromDateTime(DateTime.Today));
|
||
_settings = await TrackerService.GetSettingsAsync(_userId);
|
||
|
||
var loadWeekTask = LoadWeek();
|
||
var overtimeTask = TrackerService.GetTotalOvertimeAsync(_userId, _settings);
|
||
|
||
await Task.WhenAll(loadWeekTask, overtimeTask);
|
||
_totalOvertime = await overtimeTask;
|
||
|
||
try
|
||
{
|
||
var showOnb = await JSRuntime.InvokeAsync<string>("localStorage.getItem", "showOnboarding");
|
||
_showOnboarding = showOnb == "true";
|
||
}
|
||
catch
|
||
{
|
||
// Ignored during prerendering/SSR
|
||
}
|
||
|
||
_loading = false;
|
||
}
|
||
|
||
private async Task HandleOnboardingFinished()
|
||
{
|
||
_showOnboarding = false;
|
||
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "showOnboarding", "false");
|
||
}
|
||
|
||
private async Task LoadWeek()
|
||
{
|
||
Task<List<PublicHoliday>> holidaysTask;
|
||
if (_monday.Year != _holidayYear)
|
||
{
|
||
holidaysTask = HolidayService.GetHolidaysAsync(_monday.Year, _settings.GermanState);
|
||
}
|
||
else
|
||
{
|
||
holidaysTask = Task.FromResult(new List<PublicHoliday>());
|
||
}
|
||
|
||
var dbDaysTask = TrackerService.GetWeekAsync(_userId, _monday);
|
||
|
||
await Task.WhenAll(holidaysTask, dbDaysTask);
|
||
|
||
if (_monday.Year != _holidayYear)
|
||
{
|
||
var list = await holidaysTask;
|
||
_holidays = list.ToDictionary(h => h.Date, h => h.Name);
|
||
_holidayYear = _monday.Year;
|
||
}
|
||
|
||
var dbDays = await dbDaysTask;
|
||
_days = Enumerable.Range(0, 7).Select(i =>
|
||
{
|
||
var date = _monday.AddDays(i);
|
||
return DayVm.From(dbDays.FirstOrDefault(d => d.Date == date), date, _userId);
|
||
}).ToList();
|
||
BuildWeekLabels();
|
||
}
|
||
|
||
private void BuildWeekLabels()
|
||
{
|
||
var sunday = _monday.AddDays(6);
|
||
var kw = _deCulture.Calendar.GetWeekOfYear(
|
||
_monday.ToDateTime(TimeOnly.MinValue),
|
||
System.Globalization.CalendarWeekRule.FirstFourDayWeek,
|
||
DayOfWeek.Monday);
|
||
_weekLabel = $"KW {kw} · {_monday:dd. MMM} – {sunday:dd. MMM yyyy}";
|
||
var recorded = _days.Count(d => d.Start.HasValue || d.End.HasValue);
|
||
var workDays = _days.Count(d => _settings.IsWorkDay(d.Date.DayOfWeek));
|
||
_weekSubLabel = recorded == 0
|
||
? "Noch keine Einträge diese Woche"
|
||
: $"{recorded} von {workDays} Arbeitstagen erfasst";
|
||
}
|
||
|
||
// ── Navigation ──────────────────────────────────────────────
|
||
private async Task PrevWeek() { _monday = _monday.AddDays(-7); await LoadWeek(); }
|
||
private async Task NextWeek() { _monday = _monday.AddDays(7); await LoadWeek(); }
|
||
private async Task GoToCurrentWeek() { _monday = GetMonday(DateOnly.FromDateTime(DateTime.Today)); await LoadWeek(); }
|
||
|
||
private static DateOnly GetMonday(DateOnly date)
|
||
{
|
||
int diff = ((int)date.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7;
|
||
return date.AddDays(-diff);
|
||
}
|
||
|
||
// ── Change Handlers ──────────────────────────────────────────
|
||
private async Task OnStartChanged(DayVm day, TimeSpan? v) { day.Start = v; await SaveDay(day); }
|
||
private async Task OnEndChanged(DayVm day, TimeSpan? v) { day.End = v; await SaveDay(day); }
|
||
|
||
private async Task OnBreakStartChanged(DayVm day, int idx, TimeSpan? v)
|
||
{ day.Breaks[idx].Start = v; await SaveDay(day); }
|
||
|
||
private async Task OnBreakEndChanged(DayVm day, int idx, TimeSpan? v)
|
||
{ day.Breaks[idx].End = v; await SaveDay(day); }
|
||
|
||
private void AddBreak(DayVm day)
|
||
{
|
||
day.Breaks.Add(new BreakVm());
|
||
StateHasChanged();
|
||
}
|
||
|
||
private async Task RemoveBreak(DayVm day, int idx)
|
||
{ day.Breaks.RemoveAt(idx); await SaveDay(day); }
|
||
|
||
private async Task SaveDay(DayVm day)
|
||
{
|
||
await TrackerService.UpsertWorkDayAsync(day.ToWorkDay());
|
||
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
|
||
BuildWeekLabels();
|
||
}
|
||
|
||
// ── Berechnungen ─────────────────────────────────────────────
|
||
private TimeSpan? GetOvertime(DayVm day) =>
|
||
day.NetWork.HasValue ? day.NetWork.Value - TimeSpan.FromHours(_settings.DailyTargetHours) : null;
|
||
|
||
private int GetProgressPercent(DayVm day)
|
||
{
|
||
if (!day.NetWork.HasValue || _settings.DailyTargetHours <= 0) return -1;
|
||
return (int)(day.NetWork.Value.TotalHours / _settings.DailyTargetHours * 100);
|
||
}
|
||
|
||
private static string GetBorderColor(DayVm day, bool isWorkDay, List<string> errors)
|
||
{
|
||
if (!isWorkDay) return "#CFD8DC";
|
||
if (errors.Count > 0) return "#EF5350";
|
||
if (!day.Start.HasValue && !day.End.HasValue) return "#B0BEC5";
|
||
if (!day.NetWork.HasValue) return "#B0BEC5";
|
||
return "#4CAF50";
|
||
}
|
||
|
||
private IEnumerable<string> GetDayErrors(DayVm day)
|
||
{
|
||
if (day.Start.HasValue && day.End.HasValue && day.Start >= day.End)
|
||
yield return "Endzeit muss nach Beginn liegen.";
|
||
|
||
for (int i = 0; i < day.Breaks.Count; i++)
|
||
{
|
||
var b = day.Breaks[i];
|
||
if (b.Start.HasValue && b.End.HasValue && b.Start >= b.End)
|
||
yield return $"Pause {i + 1}: Ende muss nach Start liegen.";
|
||
if (day.Start.HasValue && b.Start.HasValue && b.Start < day.Start)
|
||
yield return $"Pause {i + 1} beginnt vor Arbeitsbeginn.";
|
||
if (day.End.HasValue && b.End.HasValue && b.End > day.End)
|
||
yield return $"Pause {i + 1} endet nach Arbeitsende.";
|
||
|
||
for (int j = i + 1; j < day.Breaks.Count; j++)
|
||
{
|
||
var c = day.Breaks[j];
|
||
if (b.Start.HasValue && b.End.HasValue && c.Start.HasValue && c.End.HasValue
|
||
&& b.Start < c.End && c.Start < b.End)
|
||
yield return $"Pause {i + 1} und Pause {j + 1} überschneiden sich.";
|
||
}
|
||
}
|
||
}
|
||
|
||
private TimeSpan WeekGross => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (d.GrossWork ?? TimeSpan.Zero));
|
||
private TimeSpan WeekBreaks => _days.Aggregate(TimeSpan.Zero, (s, d) => s + d.TotalBreakTime);
|
||
private TimeSpan WeekNet => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (d.NetWork ?? TimeSpan.Zero));
|
||
private TimeSpan WeekOvertime => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (GetOvertime(d) ?? TimeSpan.Zero));
|
||
|
||
private static string FormatTs(TimeSpan ts, bool sign = false)
|
||
{
|
||
if (ts == TimeSpan.Zero && sign) return "±0:00";
|
||
var prefix = sign ? (ts >= TimeSpan.Zero ? "+" : "−") : (ts < TimeSpan.Zero ? "−" : "");
|
||
var abs = ts.Duration();
|
||
return $"{prefix}{(int)abs.TotalHours}:{abs.Minutes:D2}";
|
||
}
|
||
|
||
// ── ViewModels ────────────────────────────────────────────────
|
||
private sealed class DayVm
|
||
{
|
||
public int Id { get; set; }
|
||
public int UserId { get; set; }
|
||
public DateOnly Date { get; set; }
|
||
public TimeSpan? Start { get; set; }
|
||
public TimeSpan? End { get; set; }
|
||
public List<BreakVm> Breaks { get; set; } = [];
|
||
|
||
public TimeSpan? GrossWork =>
|
||
Start.HasValue && End.HasValue && End > Start ? End.Value - Start.Value : null;
|
||
|
||
public TimeSpan TotalBreakTime => Breaks
|
||
.Where(b => b.Duration.HasValue)
|
||
.Aggregate(TimeSpan.Zero, (s, b) => s + b.Duration!.Value);
|
||
|
||
public TimeSpan? NetWork => GrossWork.HasValue ? GrossWork.Value - TotalBreakTime : null;
|
||
|
||
public static DayVm From(WorkDay? wd, DateOnly date, int userId) => new()
|
||
{
|
||
Id = wd?.Id ?? 0,
|
||
UserId = wd?.UserId ?? userId,
|
||
Date = date,
|
||
Start = wd?.StartTime?.ToTimeSpan(),
|
||
End = wd?.EndTime?.ToTimeSpan(),
|
||
Breaks = wd?.Breaks.Select(b => new BreakVm
|
||
{
|
||
Id = b.Id,
|
||
Start = b.StartTime?.ToTimeSpan(),
|
||
End = b.EndTime?.ToTimeSpan()
|
||
}).ToList() ?? []
|
||
};
|
||
|
||
public WorkDay ToWorkDay() => new()
|
||
{
|
||
Id = Id,
|
||
UserId = UserId,
|
||
Date = Date,
|
||
StartTime = Start.HasValue ? TimeOnly.FromTimeSpan(Start.Value) : null,
|
||
EndTime = End.HasValue ? TimeOnly.FromTimeSpan(End.Value) : null,
|
||
Breaks = Breaks.Select(b => new BreakEntry
|
||
{
|
||
Id = b.Id,
|
||
StartTime = b.Start.HasValue ? TimeOnly.FromTimeSpan(b.Start.Value) : null,
|
||
EndTime = b.End.HasValue ? TimeOnly.FromTimeSpan(b.End.Value) : null
|
||
}).ToList()
|
||
};
|
||
}
|
||
|
||
private sealed class BreakVm
|
||
{
|
||
public int Id { get; set; }
|
||
public TimeSpan? Start { get; set; }
|
||
public TimeSpan? End { get; set; }
|
||
public TimeSpan? Duration =>
|
||
Start.HasValue && End.HasValue && End > Start ? End.Value - Start.Value : null;
|
||
}
|
||
}
|
||
|