Files
timetracker/timetracker.Client/Components/Pages/Home.razor
T
2026-06-24 23:48:04 +02:00

747 lines
38 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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 mud-theme-primary">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="PrevWeek" />
<MudStack Spacing="0" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700; letter-spacing:0.5px">
@_weekLabel
</MudText>
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">
@_weekSubLabel
</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
@if (!IsCurrentWeek)
{
<MudButton Variant="Variant.Text" Style="color: var(--mud-palette-primary-text)"
OnClick="GoToCurrentWeek" Size="Size.Small">
Heute
</MudButton>
}
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
Style="color: var(--mud-palette-primary-text)" 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)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
@if (day.IsVacation)
{
<MudChip T="string" Color="Color.Info" Variant="Variant.Filled" Size="Size.Small" Style="font-weight:700">
URLAUB
</MudChip>
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Error"
OnClick="@(async () => await RemoveVacation(day))"
Style="text-transform:none; font-size: 0.75rem;">
Urlaub entfernen
</MudButton>
}
else
{
<MudChip T="string" Color="Color.Default" Variant="Variant.Outlined" Size="Size.Small">
Nicht erfasst
</MudChip>
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Info"
OnClick="@(async () => await MarkAsVacation(day))"
Style="text-transform:none; font-size: 0.75rem;">
Als Urlaub eintragen
</MudButton>
}
</MudStack>
}
</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(GetEffectiveBreakTime(day))</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(GetEffectiveNetWork(day)!.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(GetDailyTargetHours(day)))</MudText>
</MudStack>
<MudDivider Vertical="true" FlexItem="true" Style="height:36px" />
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Color="Color.Secondary">Soll-Ende</MudText>
<MudText Typo="Typo.body1">@FormatTimeSpan(CalculateEarliestDeparture(day)) Uhr</MudText>
</MudStack>
</MudStack>
</MudItem>
}
else if (day.Start.HasValue)
{
<MudItem xs="12" md="8">
<MudStack Row="true" Spacing="3" AlignItems="AlignItems.Center" Class="h-100 pl-1">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-info);">Früheste Gehenszeit</MudText>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.ExitToApp" Color="Color.Info" Size="Size.Small" />
<MudText Typo="Typo.body1" Color="Color.Info" Style="font-weight:700">
@FormatTimeSpan(CalculateEarliestDeparture(day)) Uhr
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="ml-2">
(Bei Soll: @FormatTs(TimeSpan.FromHours(_settings.DailyTargetHours))h + @((int)GetRequiredBreakForTarget(day).TotalMinutes) Min. Pause)
</MudText>
</MudStack>
</MudStack>
</MudStack>
</MudItem>
}
</MudGrid>
@* ── Fortschrittsbalken ── *@
@if (progressPct >= 0)
{
<MudTooltip Text="@($"Netto {FormatTs(GetEffectiveNetWork(day) ?? TimeSpan.Zero)} von {FormatTs(TimeSpan.FromHours(GetDailyTargetHours(day)))} 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: var(--mud-palette-primary-darken); color: var(--mud-palette-primary-text);">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-4">
<MudIcon Icon="@Icons.Material.Filled.Summarize" Style="color: var(--mud-palette-primary-text); opacity: 0.8" />
<MudText Typo="Typo.h6" Style="color: var(--mud-palette-primary-text); font-weight:600">Wochensumme</MudText>
</MudStack>
<MudGrid Spacing="3">
<MudItem xs="6" sm="3">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-primary-text); opacity: 0.6">Brutto</MudText>
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text)">@FormatTs(WeekGross)</MudText>
</MudStack>
</MudItem>
<MudItem xs="6" sm="3">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-primary-text); opacity: 0.6">Pausen</MudText>
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text)">@FormatTs(WeekBreaks)</MudText>
</MudStack>
</MudItem>
<MudItem xs="6" sm="3">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-primary-text); opacity: 0.6">Netto</MudText>
<MudText Typo="Typo.h5" Color="Color.Secondary" Style="font-weight:700">@FormatTs(WeekNet)</MudText>
</MudStack>
</MudItem>
<MudItem xs="6" sm="3">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-primary-text); opacity: 0.6">Gleitzeit</MudText>
<MudText Typo="Typo.h5" Color="@(WeekOvertime >= TimeSpan.Zero ? Color.Success : Color.Warning)" Style="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 List<VacationDay> _vacations = [];
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);
var vacationsTask = TrackerService.GetVacationDaysAsync(_userId, _monday.Year);
await Task.WhenAll(holidaysTask, dbDaysTask, vacationsTask);
if (_monday.Year != _holidayYear)
{
var list = await holidaysTask;
_holidays = list.ToDictionary(h => h.Date, h => h.Name);
_holidayYear = _monday.Year;
}
_vacations = await vacationsTask;
var dbDays = await dbDaysTask;
_days = Enumerable.Range(0, 7).Select(i =>
{
var date = _monday.AddDays(i);
var dayVm = DayVm.From(dbDays.FirstOrDefault(d => d.Date == date), date, _userId);
dayVm.IsVacation = _vacations.Any(v => v.Date == date);
return dayVm;
}).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 GetEffectiveBreakTime(DayVm day)
{
var enteredBreak = day.Breaks
.Where(b => b.Duration.HasValue)
.Aggregate(TimeSpan.Zero, (s, b) => s + b.Duration!.Value);
if (!day.Start.HasValue || !day.End.HasValue)
return enteredBreak;
var gross = day.End.Value - day.Start.Value;
int minMinutes = gross.TotalHours >= 9.0 ? 45 : 30;
int finalMin = Math.Max(minMinutes, _settings.MinimumBreakMinutes);
var minBreak = TimeSpan.FromMinutes(finalMin);
return enteredBreak > minBreak ? enteredBreak : minBreak;
}
private TimeSpan? GetEffectiveNetWork(DayVm day)
{
if (!day.GrossWork.HasValue) return null;
return day.GrossWork.Value - GetEffectiveBreakTime(day);
}
private double GetDailyTargetHours(DayVm day)
{
var isHoliday = _holidays.ContainsKey(day.Date);
var isWorkDay = _settings.IsWorkDay(day.Date.DayOfWeek);
if (!isWorkDay || isHoliday || day.IsVacation)
{
return 0.0;
}
return _settings.DailyTargetHours;
}
private TimeSpan? GetOvertime(DayVm day)
{
var net = GetEffectiveNetWork(day);
return net.HasValue ? net.Value - TimeSpan.FromHours(GetDailyTargetHours(day)) : null;
}
private int GetProgressPercent(DayVm day)
{
var net = GetEffectiveNetWork(day);
var target = GetDailyTargetHours(day);
if (!net.HasValue || target <= 0) return -1;
return (int)(net.Value.TotalHours / target * 100);
}
private string GetBorderColor(DayVm day, bool isWorkDay, List<string> errors)
{
if (!isWorkDay) return "#CFD8DC";
if (errors.Count > 0) return "#EF5350";
if (day.IsVacation) return "#0EA5E9"; // Cool blue for vacation
if (!day.Start.HasValue && !day.End.HasValue) return "#B0BEC5";
if (!GetEffectiveNetWork(day).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.";
}
}
var net = GetEffectiveNetWork(day);
if (net.HasValue && net.Value.TotalHours > 10.0)
{
yield return "Die tägliche Arbeitszeit überschreitet das gesetzliche Maximum von 10 Stunden!";
}
}
private TimeSpan WeekGross => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (d.GrossWork ?? TimeSpan.Zero));
private TimeSpan WeekBreaks => _days.Aggregate(TimeSpan.Zero, (s, d) => s + GetEffectiveBreakTime(d));
private TimeSpan WeekNet => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (GetEffectiveNetWork(d) ?? 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}";
}
private TimeSpan GetRequiredBreakForTarget(DayVm day)
{
var targetNet = _settings.DailyTargetHours;
var minMinutes = targetNet >= 8.5 ? 45 : 30;
var requiredBreak = TimeSpan.FromMinutes(Math.Max(minMinutes, _settings.MinimumBreakMinutes));
var enteredBreak = day.Breaks
.Where(b => b.Duration.HasValue)
.Aggregate(TimeSpan.Zero, (s, b) => s + b.Duration!.Value);
return enteredBreak > requiredBreak ? enteredBreak : requiredBreak;
}
private TimeSpan CalculateEarliestDeparture(DayVm day)
{
if (!day.Start.HasValue) return TimeSpan.Zero;
var target = TimeSpan.FromHours(_settings.DailyTargetHours);
var finalBreak = GetRequiredBreakForTarget(day);
return day.Start.Value + target + finalBreak;
}
private async Task MarkAsVacation(DayVm day)
{
var vacation = new VacationDay
{
UserId = _userId,
Date = day.Date,
Note = "Über Wochenübersicht eingetragen"
};
await TrackerService.AddVacationDayAsync(vacation);
await LoadWeek();
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
Snackbar.Add($"{day.Date:dd.MM.yyyy} als Urlaub eingetragen.", Severity.Success);
}
private async Task RemoveVacation(DayVm day)
{
var item = _vacations.FirstOrDefault(v => v.Date == day.Date);
if (item != null)
{
await TrackerService.RemoveVacationDayAsync(_userId, item.Id);
await LoadWeek();
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
Snackbar.Add($"Urlaub für {day.Date:dd.MM.yyyy} entfernt.", Severity.Info);
}
}
private static string FormatTimeSpan(TimeSpan? ts)
{
if (!ts.HasValue) return "";
var hours = ((int)ts.Value.TotalHours) % 24;
return $"{hours:D2}:{ts.Value.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 bool IsVacation { 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;
}
}