first commit

This commit is contained in:
Wieland, Marc
2026-05-22 09:18:01 +02:00
commit 88ac175190
346 changed files with 69358 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}
+207
View File
@@ -0,0 +1,207 @@
@page "/feiertage"
@rendermode InteractiveServer
@inject HolidayService HolidayService
<PageTitle>Feiertage Timetracker</PageTitle>
@if (_loading)
{
<MudStack AlignItems="AlignItems.Center" Class="mt-16" Spacing="3">
<MudProgressCircular Color="Color.Tertiary" Indeterminate="true" Size="Size.Large" />
<MudText Color="Color.Secondary">Lade Feiertage…</MudText>
</MudStack>
}
else
{
<MudStack Spacing="3">
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, #00897B 0%, #004D40 100%); color: white;">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
Style="color:white" Size="Size.Large" OnClick="PrevYear" />
<MudStack Spacing="0" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5" Style="color:white; font-weight:700; letter-spacing:0.5px">
Feiertage @_year
</MudText>
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
@_subLabel
</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
@if (_year != DateTime.Today.Year)
{
<MudButton Variant="Variant.Text" Style="color:white"
OnClick="GoToCurrentYear" Size="Size.Small">
Heute
</MudButton>
}
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
Style="color:white" Size="Size.Large" OnClick="NextYear" />
</MudStack>
</MudStack>
</MudPaper>
@if (_holidays.Count == 0)
{
@* ── Keine Daten ── *@
<MudPaper Elevation="2" Class="pa-8 rounded-xl text-center">
<MudIcon Icon="@Icons.Material.Filled.Celebration"
Style="font-size:4rem; color:#B2DFDB;" Class="mb-3" />
<MudText Typo="Typo.h6" Color="Color.Secondary">
Keine Feiertage für @_year gespeichert.
</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mt-1">
Gehe zu <b>Einstellungen → Feiertage</b> und klicke „Von API laden".
</MudText>
</MudPaper>
}
else
{
@* ── Kacheln nach Monat gruppiert ── *@
@foreach (var group in _holidays.GroupBy(h => h.Date.Month).OrderBy(g => g.Key))
{
<MudStack Spacing="2">
<MudText Typo="Typo.overline" Color="Color.Secondary"
Style="font-weight:700; letter-spacing:2px; padding-left:4px">
@_deCulture.DateTimeFormat.GetMonthName(group.Key).ToUpper()
</MudText>
<MudGrid Spacing="3">
@foreach (var h in group.OrderBy(x => x.Date))
{
var isPast = h.Date < DateOnly.FromDateTime(DateTime.Today);
var isToday = h.Date == DateOnly.FromDateTime(DateTime.Today);
var daysLeft = h.Date.DayNumber - DateOnly.FromDateTime(DateTime.Today).DayNumber;
<MudItem xs="12" sm="6" md="4" lg="3">
<MudCard Elevation="@(isToday ? 6 : 2)" Class="rounded-xl h-100"
Style="@($"border-top: 4px solid {(isToday ? "#FF6F00" : isPast ? "#B2DFDB" : "#00897B")}; opacity:{(isPast && !isToday ? "0.7" : "1")};")">
<MudCardContent Class="pa-4">
<MudStack Spacing="2">
@* Icon + Datum *@
<MudStack Row="true" AlignItems="AlignItems.Center"
Justify="Justify.SpaceBetween">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.Celebration"
Style="@($"color:{(isToday ? "#FF6F00" : isPast ? "#80CBC4" : "#00897B")}; font-size:1.3rem")" />
<MudText Typo="Typo.caption"
Style="@($"color:{(isToday ? "#FF6F00" : isPast ? "#90A4AE" : "#00897B")}; font-weight:700")">
@h.Date.ToString("dd. MMM", _deCulture)
</MudText>
</MudStack>
@if (isToday)
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled"
Style="height:20px; font-size:10px; font-weight:700; background:#FF6F00; color:white;">
HEUTE
</MudChip>
}
else if (!isPast)
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined"
Style="@($"height:20px; font-size:10px; color:#00897B; border-color:#00897B;")">
@(daysLeft == 1 ? "morgen" : $"in {daysLeft} Tagen")
</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined"
Style="height:20px; font-size:10px; color:#90A4AE; border-color:#CFD8DC;">
vergangen
</MudChip>
}
</MudStack>
@* Name *@
<MudText Typo="Typo.h6"
Style="@($"font-weight:700; line-height:1.3; color:{(isPast && !isToday ? "#90A4AE" : "inherit")}")">
@h.Name
</MudText>
@* Wochentag *@
<MudText Typo="Typo.caption" Color="Color.Secondary">
@h.Date.ToString("dddd, dd. MMMM yyyy", _deCulture)
</MudText>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
}
</MudGrid>
</MudStack>
}
@* ── Zusammenfassung ── *@
<MudPaper Elevation="2" Class="pa-4 rounded-xl"
Style="background: linear-gradient(90deg, rgba(0,137,123,0.08) 0%, transparent 100%); border-left: 4px solid #00897B;">
<MudStack Row="true" Spacing="4" Wrap="Wrap.Wrap" AlignItems="AlignItems.Center">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.Celebration" Style="color:#00897B" />
<MudText Typo="Typo.body2" Style="font-weight:600">@_holidays.Count Feiertage gesamt</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Style="color:#4CAF50" />
<MudText Typo="Typo.body2" Color="Color.Secondary">
@_holidays.Count(h => h.Date < DateOnly.FromDateTime(DateTime.Today)) vergangen
</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.Schedule" Style="color:#FF9800" />
<MudText Typo="Typo.body2" Color="Color.Secondary">
@_holidays.Count(h => h.Date >= DateOnly.FromDateTime(DateTime.Today)) noch ausstehend
</MudText>
</MudStack>
@{
var next = _holidays
.Where(h => h.Date > DateOnly.FromDateTime(DateTime.Today))
.OrderBy(h => h.Date)
.FirstOrDefault();
}
@if (next != null)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.NavigateNext" Style="color:#00897B" />
<MudText Typo="Typo.body2" Color="Color.Secondary">
Nächster: <b>@next.Name</b> (@next.Date.ToString("dd. MMM", _deCulture))
</MudText>
</MudStack>
}
</MudStack>
</MudPaper>
}
</MudStack>
}
@code {
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
private bool _loading = true;
private int _year = DateTime.Today.Year;
private List<PublicHoliday> _holidays = [];
private string _subLabel = "";
protected override async Task OnInitializedAsync()
{
await LoadHolidays();
_loading = false;
}
private async Task LoadHolidays()
{
_holidays = await HolidayService.GetHolidaysAsync(_year);
var count = _holidays.Count;
var past = _holidays.Count(h => h.Date < DateOnly.FromDateTime(DateTime.Today));
_subLabel = count == 0
? "Keine Daten gespeichert"
: $"{count} Feiertage · {past} vergangen · {count - past} ausstehend";
}
private async Task PrevYear() { _year--; await LoadHolidays(); }
private async Task NextYear() { _year++; await LoadHolidays(); }
private async Task GoToCurrentYear(){ _year = DateTime.Today.Year; await LoadHolidays(); }
}
+541
View File
@@ -0,0 +1,541 @@
@page "/"
@rendermode InteractiveServer
@inject TimetrackerService TrackerService
@inject HolidayService HolidayService
@inject ISnackbar Snackbar
<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"
Style="background: linear-gradient(135deg, #3F51B5 0%, #1A237E 100%); 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"
Style="@($"border-left: 4px solid {borderColor};")">
<MudCardHeader Style="@(isToday ? "background: linear-gradient(90deg, rgba(63,81,181,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"
Style="background: linear-gradient(135deg, #1A237E 0%, #283593 100%); 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"
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>
}
@code {
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
private bool _loading = true;
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 IsCurrentWeek => _monday == GetMonday(DateOnly.FromDateTime(DateTime.Today));
protected override async Task OnInitializedAsync()
{
_monday = GetMonday(DateOnly.FromDateTime(DateTime.Today));
_settings = await TrackerService.GetSettingsAsync();
await LoadWeek();
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_settings);
_loading = false;
}
private async Task LoadWeek()
{
if (_monday.Year != _holidayYear)
{
var list = await HolidayService.GetHolidaysAsync(_monday.Year);
_holidays = list.ToDictionary(h => h.Date, h => h.Name);
_holidayYear = _monday.Year;
}
var dbDays = await TrackerService.GetWeekAsync(_monday);
_days = Enumerable.Range(0, 7).Select(i =>
{
var date = _monday.AddDays(i);
return DayVm.From(dbDays.FirstOrDefault(d => d.Date == date), date);
}).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(_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 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) => new()
{
Id = wd?.Id ?? 0,
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,
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;
}
}
+307
View File
@@ -0,0 +1,307 @@
@page "/month"
@rendermode InteractiveServer
@inject TimetrackerService TrackerService
@inject HolidayService HolidayService
<PageTitle>@_deCulture.DateTimeFormat.GetMonthName(_month) @_year Monatsü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 Monatsdaten…</MudText>
</MudStack>
}
else
{
<MudStack Spacing="3">
@* ── Monats-Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, #3F51B5 0%, #1A237E 100%); color: white;">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
Style="color:white" Size="Size.Large" OnClick="PrevMonth" />
<MudStack Spacing="0" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5" Style="color:white; font-weight:700; letter-spacing:0.5px">
@_deCulture.DateTimeFormat.GetMonthName(_month) @_year
</MudText>
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
@_subLabel
</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
@if (!IsCurrentMonth)
{
<MudButton Variant="Variant.Text" Style="color:white"
OnClick="GoToCurrentMonth" Size="Size.Small">
Heute
</MudButton>
}
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
Style="color:white" Size="Size.Large" OnClick="NextMonth" />
</MudStack>
</MudStack>
</MudPaper>
@* ── Monatstabelle ── *@
<MudCard Elevation="3" Class="rounded-xl">
<MudCardContent Class="pa-0">
<MudSimpleTable Dense="true" Striped="false" Hover="true" Style="overflow-x:auto">
<thead>
<tr style="background: rgba(63,81,181,0.08);">
<th style="font-weight:700; padding:10px 16px">Tag</th>
<th style="font-weight:700; padding:10px 16px">Start</th>
<th style="font-weight:700; padding:10px 16px">Ende</th>
<th style="font-weight:700; padding:10px 16px">Netto</th>
<th style="font-weight:700; padding:10px 16px">Gleitzeit</th>
<th style="font-weight:700; padding:10px 16px">Status</th>
</tr>
</thead>
<tbody>
@foreach (var d in _days)
{
<tr style="@GetRowStyle(d)">
<td style="padding:8px 16px; white-space:nowrap">
<MudStack Spacing="0">
<MudText Typo="Typo.body2" Style="@($"font-weight:{(d.IsToday ? "700" : "500")}")">
@d.Date.ToString("ddd, dd. MMM", _deCulture)
</MudText>
</MudStack>
</td>
<td style="padding:8px 16px">
@(d.StartTime.HasValue ? d.StartTime.Value.ToString(@"HH\:mm") : "—")
</td>
<td style="padding:8px 16px">
@(d.EndTime.HasValue ? d.EndTime.Value.ToString(@"HH\:mm") : "—")
</td>
<td style="padding:8px 16px">
@(d.Net.HasValue ? FormatTs(d.Net.Value) : "—")
</td>
<td style="padding:8px 16px; color:@(d.Overtime >= TimeSpan.Zero ? "#4CAF50" : "#FF9800"); font-weight:600">
@(d.Net.HasValue ? FormatTs(d.Overtime, sign: true) : "—")
</td>
<td style="padding:8px 16px">
@GetStatusChip(d)
</td>
</tr>
}
</tbody>
</MudSimpleTable>
</MudCardContent>
</MudCard>
@* ── Monatszusammenfassung ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, rgba(63,81,181,0.08) 0%, rgba(26,35,126,0.04) 100%); border-left: 6px solid #3F51B5;">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-4">
<MudIcon Icon="@Icons.Material.Filled.CalendarViewMonth" Color="Color.Primary" />
<MudText Typo="Typo.h6" Style="font-weight:700">Monatszusammenfassung</MudText>
</MudStack>
<MudGrid Spacing="3">
<MudItem xs="6" sm="4" md="2">
<MudStack Spacing="0" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5" Color="Color.Primary" Style="font-weight:700">
@FormatTs(_monthNet)
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Netto gesamt</MudText>
</MudStack>
</MudItem>
<MudItem xs="6" sm="4" md="2">
<MudStack Spacing="0" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5"
Style="@($"font-weight:700; color:{(_monthOvertime >= TimeSpan.Zero ? "#4CAF50" : "#FF9800")}")">
@FormatTs(_monthOvertime, sign: true)
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Gleitzeit</MudText>
</MudStack>
</MudItem>
<MudItem xs="6" sm="4" md="2">
<MudStack Spacing="0" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5" Color="Color.Default" Style="font-weight:700">
@_recordedWorkDays / @_totalWorkDays
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Arbeitstage</MudText>
</MudStack>
</MudItem>
<MudItem xs="6" sm="4" md="2">
<MudStack Spacing="0" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5" Color="Color.Secondary" Style="font-weight:700">
@_vacationCount
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Urlaubstage</MudText>
</MudStack>
</MudItem>
<MudItem xs="6" sm="4" md="2">
<MudStack Spacing="0" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5" Color="Color.Tertiary" Style="font-weight:700">
@_holidayCount
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Feiertage</MudText>
</MudStack>
</MudItem>
</MudGrid>
</MudPaper>
</MudStack>
}
@code {
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
private bool _loading = true;
private int _year = DateTime.Today.Year;
private int _month = DateTime.Today.Month;
private List<MonthDayVm> _days = [];
private AppSettings _settings = new();
private string _subLabel = "";
private TimeSpan _monthNet;
private TimeSpan _monthOvertime;
private int _recordedWorkDays;
private int _totalWorkDays;
private int _vacationCount;
private int _holidayCount;
private bool IsCurrentMonth => _year == DateTime.Today.Year && _month == DateTime.Today.Month;
protected override async Task OnInitializedAsync()
{
_settings = await TrackerService.GetSettingsAsync();
await LoadMonth();
_loading = false;
}
private async Task LoadMonth()
{
var workDays = await TrackerService.GetMonthAsync(_year, _month);
var holidays = await HolidayService.GetHolidaysAsync(_year);
var vacations = await TrackerService.GetVacationDaysAsync(_year);
var holidayMap = holidays.ToDictionary(h => h.Date, h => h.Name);
var vacationSet = vacations.Select(v => v.Date).ToHashSet();
var daysInMonth = DateTime.DaysInMonth(_year, _month);
var today = DateOnly.FromDateTime(DateTime.Today);
_days = Enumerable.Range(1, daysInMonth).Select(day =>
{
var date = new DateOnly(_year, _month, day);
var wd = workDays.FirstOrDefault(w => w.Date == date);
var isWorkDay = _settings.IsWorkDay(date.DayOfWeek);
TimeSpan? net = null;
if (wd?.StartTime != null && wd.EndTime != null)
{
var gross = wd.EndTime.Value.ToTimeSpan() - wd.StartTime.Value.ToTimeSpan();
var breaks = wd.Breaks
.Where(b => b.StartTime.HasValue && b.EndTime.HasValue && b.EndTime > b.StartTime)
.Aggregate(TimeSpan.Zero, (s, b) =>
s + (b.EndTime!.Value.ToTimeSpan() - b.StartTime!.Value.ToTimeSpan()));
net = gross - breaks;
if (net < TimeSpan.Zero) net = null;
}
var target = isWorkDay ? TimeSpan.FromHours(_settings.DailyTargetHours) : TimeSpan.Zero;
var overtime = net.HasValue ? net.Value - target : TimeSpan.Zero;
return new MonthDayVm
{
Date = date,
StartTime = wd?.StartTime,
EndTime = wd?.EndTime,
Net = net,
Overtime = overtime,
HolidayName = holidayMap.GetValueOrDefault(date),
IsVacation = vacationSet.Contains(date),
IsWorkDay = isWorkDay,
IsToday = date == today
};
}).ToList();
// Summaries
_monthNet = _days.Where(d => d.Net.HasValue).Aggregate(TimeSpan.Zero, (s, d) => s + d.Net!.Value);
_monthOvertime = _days
.Where(d => d.IsWorkDay && d.Net.HasValue)
.Aggregate(TimeSpan.Zero, (s, d) => s + d.Overtime);
_recordedWorkDays = _days.Count(d => d.IsWorkDay && d.Net.HasValue);
_totalWorkDays = _days.Count(d => d.IsWorkDay && string.IsNullOrEmpty(d.HolidayName) && !d.IsVacation);
_vacationCount = _days.Count(d => d.IsVacation);
_holidayCount = _days.Count(d => !string.IsNullOrEmpty(d.HolidayName));
var recorded = _recordedWorkDays;
var total = _totalWorkDays;
_subLabel = recorded == 0 ? "Noch keine Einträge diesen Monat" : $"{recorded} von {total} Arbeitstagen erfasst";
}
private async Task PrevMonth()
{
var d = new DateOnly(_year, _month, 1).AddMonths(-1);
_year = d.Year; _month = d.Month;
await LoadMonth();
}
private async Task NextMonth()
{
var d = new DateOnly(_year, _month, 1).AddMonths(1);
_year = d.Year; _month = d.Month;
await LoadMonth();
}
private async Task GoToCurrentMonth()
{
_year = DateTime.Today.Year; _month = DateTime.Today.Month;
await LoadMonth();
}
private static string FormatTs(TimeSpan ts, bool sign = false)
{
var neg = ts < TimeSpan.Zero;
var abs = neg ? ts.Negate() : ts;
var h = (int)abs.TotalHours;
var m = abs.Minutes;
if (!sign) return $"{h}:{m:D2} h";
return neg ? $"{h}:{m:D2} h" : $"+{h}:{m:D2} h";
}
private string GetRowStyle(MonthDayVm d)
{
if (d.IsToday) return "background: rgba(63,81,181,0.10);";
if (!string.IsNullOrEmpty(d.HolidayName)) return "background: rgba(0,150,136,0.07);";
if (d.IsVacation) return "background: rgba(255,152,0,0.08);";
if (!d.IsWorkDay) return "background: rgba(0,0,0,0.03); color: #90A4AE;";
if (d.Net.HasValue) return "background: rgba(76,175,80,0.06);";
return "";
}
private RenderFragment GetStatusChip(MonthDayVm d) => builder =>
{
void Chip(string text, string color)
{
builder.OpenElement(0, "span");
builder.AddAttribute(1, "style",
$"display:inline-block; padding:2px 10px; border-radius:12px; font-size:0.75rem; font-weight:600; background:{color}20; color:{color}; border:1px solid {color}60;");
builder.AddContent(2, text);
builder.CloseElement();
}
if (d.IsToday && !d.Net.HasValue && d.IsWorkDay) Chip("Heute", "#3F51B5");
else if (!string.IsNullOrEmpty(d.HolidayName)) Chip(d.HolidayName, "#009688");
else if (d.IsVacation) Chip("Urlaub", "#FF9800");
else if (!d.IsWorkDay) Chip("Frei", "#90A4AE");
else if (d.Net.HasValue) Chip("Erfasst", "#4CAF50");
else Chip("Ausstehend", "#CFD8DC");
};
private sealed class MonthDayVm
{
public DateOnly Date { get; set; }
public TimeOnly? StartTime { get; set; }
public TimeOnly? EndTime { get; set; }
public TimeSpan? Net { get; set; }
public TimeSpan Overtime { get; set; }
public string? HolidayName { get; set; }
public bool IsVacation { get; set; }
public bool IsWorkDay { get; set; }
public bool IsToday { get; set; }
}
}
+5
View File
@@ -0,0 +1,5 @@
@page "/not-found"
@layout MainLayout
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>
+489
View File
@@ -0,0 +1,489 @@
@page "/settings"
@rendermode InteractiveServer
@inject TimetrackerService TrackerService
@inject HolidayService HolidayService
@inject ISnackbar Snackbar
<PageTitle>Einstellungen Timetracker</PageTitle>
@if (_settings == null)
{
<MudStack AlignItems="AlignItems.Center" Class="mt-16">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
</MudStack>
}
else
{
<MudStack Spacing="4">
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, #3F51B5 0%, #1A237E 100%); color:white;">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudIcon Icon="@Icons.Material.Filled.Settings" Style="color:white; font-size:2rem" />
<MudStack Spacing="0">
<MudText Typo="Typo.h5" Style="color:white; font-weight:700">Einstellungen</MudText>
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
Arbeitszeit, Arbeitstage und Urlaub konfigurieren
</MudText>
</MudStack>
</MudStack>
</MudPaper>
<MudGrid Spacing="4">
@* ── Arbeitszeit ── *@
<MudItem xs="12" md="6">
<MudCard Elevation="3" Class="rounded-xl h-100">
<MudCardHeader>
<CardHeaderContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.Schedule" Color="Color.Primary" />
<MudText Typo="Typo.h6" Style="font-weight:600">Arbeitszeit</MudText>
</MudStack>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudStack Spacing="4">
<MudNumericField @bind-Value="_settings.DailyTargetHours"
Label="Sollstunden pro Tag (h)"
Variant="Variant.Outlined"
Min="0.5" Max="24.0" Step="0.25"
Format="0.##"
HelperText="Vertraglich vereinbarte Nettoarbeitszeit" />
<MudNumericField @bind-Value="_settings.MinimumBreakMinutes"
Label="Gesetzliche Mindestpause (min)"
Variant="Variant.Outlined"
Min="0" Max="120" Step="5"
HelperText="Pflichtpause laut Arbeitszeitgesetz" />
<MudPaper Elevation="0" Class="pa-3 rounded-lg"
Style="background: var(--mud-palette-background-grey);">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary" Class="mb-2">
Tagesberechnung
</MudText>
<MudStack Spacing="1">
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Color="Color.Secondary">Netto (Soll)</MudText>
<MudText Typo="Typo.body2"><b>@FormatHours(_settings.DailyTargetHours)</b></MudText>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Color="Color.Secondary">+ Mindestpause</MudText>
<MudText Typo="Typo.body2"><b>@_settings.MinimumBreakMinutes min</b></MudText>
</MudStack>
<MudDivider />
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Color="Color.Secondary">= Brutto-Anwesenheit</MudText>
<MudText Typo="Typo.body2" Color="Color.Primary">
<b>@FormatHours(_settings.DailyTargetHours + _settings.MinimumBreakMinutes / 60.0)</b>
</MudText>
</MudStack>
</MudStack>
</MudPaper>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
@* ── Arbeitstage ── *@
<MudItem xs="12" md="6">
<MudCard Elevation="3" Class="rounded-xl h-100">
<MudCardHeader>
<CardHeaderContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.CalendarToday" Color="Color.Primary" />
<MudText Typo="Typo.h6" Style="font-weight:600">Arbeitstage</MudText>
</MudStack>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3">
Wähle die Wochentage, an denen du arbeitest.
</MudText>
<MudStack Spacing="2">
@foreach (var (label, getter, setter) in WorkDayToggles)
{
var isChecked = getter(_settings);
<MudPaper Elevation="0" Class="pa-2 rounded-lg"
Style="@($"background: {(isChecked ? "rgba(63,81,181,0.08)" : "var(--mud-palette-background-grey)")};")">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.Circle"
Style="@($"font-size:10px; color:{(isChecked ? "#3F51B5" : "#CFD8DC")}")" />
<MudText Typo="Typo.body1" Style="@(isChecked ? "font-weight:600" : "")">
@label
</MudText>
</MudStack>
<MudSwitch Value="@isChecked"
ValueChanged="@((bool v) => setter(_settings, v))"
Color="Color.Primary" />
</MudStack>
</MudPaper>
}
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
</MudGrid>
@* ── Speichern-Button ── *@
<MudButton Variant="Variant.Filled" Color="Color.Primary"
OnClick="Save" StartIcon="@Icons.Material.Filled.Save"
Size="Size.Large" Style="max-width:300px">
Einstellungen speichern
</MudButton>
<MudDivider />
@* ── Urlaubsverwaltung ── *@
<MudCard Elevation="3" Class="rounded-xl">
<MudCardHeader Style="background: linear-gradient(90deg, rgba(0,150,136,0.1) 0%, transparent 100%);">
<CardHeaderContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Wrap="Wrap.Wrap">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.BeachAccess" Color="Color.Secondary" />
<MudText Typo="Typo.h6" Style="font-weight:600">Urlaubsverwaltung</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
Size="Size.Small" OnClick="@(() => ChangeYear(-1))" />
<MudText Typo="Typo.h6" Style="font-weight:700; min-width:50px; text-align:center">
@_vacYear
</MudText>
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
Size="Size.Small" OnClick="@(() => ChangeYear(1))" />
</MudStack>
</MudStack>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudGrid Spacing="4">
@* ── Urlaubskontingent ── *@
<MudItem xs="12" md="5">
<MudStack Spacing="3">
<MudNumericField @bind-Value="_settings.VacationDaysPerYear"
Label="Urlaubstage pro Jahr"
Variant="Variant.Outlined"
Min="1" Max="365" Step="1"
HelperText="Dein jährliches Urlaubskontingent" />
@* ── Statistik-Chips ── *@
<MudGrid Spacing="2">
<MudItem xs="4">
<MudPaper Elevation="0" Class="pa-3 rounded-lg text-center"
Style="background: rgba(63,81,181,0.08);">
<MudText Typo="Typo.h5" Color="Color.Primary" Style="font-weight:700">
@_settings.VacationDaysPerYear
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Gesamt</MudText>
</MudPaper>
</MudItem>
<MudItem xs="4">
<MudPaper Elevation="0" Class="pa-3 rounded-lg text-center"
Style="background: rgba(244,67,54,0.08);">
<MudText Typo="Typo.h5" Color="Color.Error" Style="font-weight:700">
@_vacationDays.Count
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Genommen</MudText>
</MudPaper>
</MudItem>
<MudItem xs="4">
<MudPaper Elevation="0" Class="pa-3 rounded-lg text-center"
Style="@($"background: rgba({(_vacRemaining >= 0 ? "76,175,80" : "255,152,0")},0.08);")">
<MudText Typo="Typo.h5"
Color="@(_vacRemaining >= 0 ? Color.Success : Color.Warning)"
Style="font-weight:700">
@_vacRemaining
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Verbleibend</MudText>
</MudPaper>
</MudItem>
</MudGrid>
@* ── Fortschrittsbalken ── *@
<MudTooltip Text="@($"{_vacationDays.Count} von {_settings.VacationDaysPerYear} Tagen genommen")">
<MudProgressLinear Value="@Math.Min(VacationUsedPercent, 100)"
Color="@(VacationUsedPercent > 100 ? Color.Error : VacationUsedPercent > 80 ? Color.Warning : Color.Success)"
Rounded="true" Style="height:10px" />
</MudTooltip>
<MudText Typo="Typo.caption" Color="Color.Secondary" Align="Align.Center">
@VacationUsedPercent % des Jahresurlaubs @_vacYear verbraucht
</MudText>
</MudStack>
</MudItem>
@* ── Urlaub hinzufügen ── *@
<MudItem xs="12" md="7">
<MudStack Spacing="3">
<MudText Typo="Typo.subtitle2" Style="font-weight:600">Urlaub eintragen</MudText>
<MudStack Row="true" AlignItems="AlignItems.End" Spacing="2" Wrap="Wrap.Wrap">
<MudDatePicker @bind-Date="_newVacDateFrom"
Label="Von"
Variant="Variant.Outlined"
DateFormat="dd.MM.yyyy"
Style="width:300px"
PickerVariant="PickerVariant.Inline" />
<MudDatePicker @bind-Date="_newVacDateTo"
Label="Bis"
Variant="Variant.Outlined"
DateFormat="dd.MM.yyyy"
Style="width:300px"
MinDate="@_newVacDateFrom"
PickerVariant="PickerVariant.Inline" />
<MudTextField @bind-Value="_newVacNote"
Label="Notiz (optional)"
Variant="Variant.Outlined"
Style="width:300px" />
<MudButton Variant="Variant.Filled" Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="AddVacation" Disabled="@(_newVacDateFrom == null)">
Hinzufügen
</MudButton>
</MudStack>
@* ── Liste der Urlaubstage ── *@
@if (_vacationDays.Count == 0)
{
<MudPaper Elevation="0" Class="pa-4 rounded-lg text-center"
Style="background: var(--mud-palette-background-grey);">
<MudIcon Icon="@Icons.Material.Filled.BeachAccess"
Style="font-size:2.5rem; color:#CFD8DC" />
<MudText Color="Color.Secondary" Class="mt-1">
Noch keine Urlaubstage für @_vacYear eingetragen.
</MudText>
</MudPaper>
}
else
{
<MudList T="VacationDay" Dense="true">
@foreach (var v in _vacationDays)
{
<MudListItem>
<MudStack Row="true" AlignItems="AlignItems.Center"
Justify="Justify.SpaceBetween">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.BeachAccess"
Size="Size.Small" Color="Color.Secondary" />
<MudStack Spacing="0">
<MudText Typo="Typo.body2" Style="font-weight:600">
@v.Date.ToString("dddd, dd. MMMM yyyy", _deCulture)
</MudText>
@if (!string.IsNullOrWhiteSpace(v.Note))
{
<MudText Typo="Typo.caption" Color="Color.Secondary">
@v.Note
</MudText>
}
</MudStack>
</MudStack>
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline"
Size="Size.Small" Color="Color.Error"
OnClick="@(async () => await RemoveVacation(v.Id))" />
</MudStack>
</MudListItem>
<MudDivider />
}
</MudList>
}
</MudStack>
</MudItem>
</MudGrid>
</MudCardContent>
</MudCard>
@* ── Feiertagsverwaltung ── *@
<MudCard Elevation="3" Class="rounded-xl">
<MudCardHeader Style="background: linear-gradient(90deg, rgba(0,188,212,0.1) 0%, transparent 100%);">
<CardHeaderContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Wrap="Wrap.Wrap">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.Celebration" Color="Color.Tertiary" />
<MudText Typo="Typo.h6" Style="font-weight:600">Feiertage (Deutschland)</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
Size="Size.Small" OnClick="@(() => ChangeHolYear(-1))" />
<MudText Typo="Typo.h6" Style="font-weight:700; min-width:50px; text-align:center">
@_holYear
</MudText>
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
Size="Size.Small" OnClick="@(() => ChangeHolYear(1))" />
<MudButton Variant="Variant.Filled" Color="Color.Tertiary"
StartIcon="@Icons.Material.Filled.CloudDownload"
OnClick="FetchHolidays"
Disabled="@_fetchingHolidays"
Size="Size.Small">
@if (_fetchingHolidays)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
}
Von API laden
</MudButton>
</MudStack>
</MudStack>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
@if (_holHolidays.Count == 0)
{
<MudPaper Elevation="0" Class="pa-4 rounded-lg text-center"
Style="background: var(--mud-palette-background-grey);">
<MudIcon Icon="@Icons.Material.Filled.Celebration"
Style="font-size:2.5rem; color:#CFD8DC" />
<MudText Color="Color.Secondary" Class="mt-1">
Keine Feiertage f&#252;r @_holYear gespeichert. Klicke "Von API laden".
</MudText>
</MudPaper>
}
else
{
<MudList T="PublicHoliday" Dense="true">
@foreach (var h in _holHolidays)
{
<MudListItem>
<MudStack Row="true" AlignItems="AlignItems.Center"
Justify="Justify.SpaceBetween">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.Celebration"
Size="Size.Small" Color="Color.Tertiary" />
<MudStack Spacing="0">
<MudText Typo="Typo.body2" Style="font-weight:600">
@h.Date.ToString("dddd, dd. MMMM yyyy", _deCulture)
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">@h.Name</MudText>
</MudStack>
</MudStack>
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline"
Size="Size.Small" Color="Color.Error"
OnClick="@(async () => await DeleteHoliday(h.Id))" />
</MudStack>
</MudListItem>
<MudDivider />
}
</MudList>
}
</MudCardContent>
</MudCard>
</MudStack>
}
@code {
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
private AppSettings? _settings;
private int _vacYear = DateTime.Today.Year;
private List<VacationDay> _vacationDays = [];
private DateTime? _newVacDateFrom;
private DateTime? _newVacDateTo;
private string _newVacNote = "";
private int _holYear = DateTime.Today.Year;
private List<PublicHoliday> _holHolidays = [];
private bool _fetchingHolidays;
private int _vacRemaining => (_settings?.VacationDaysPerYear ?? 0) - _vacationDays.Count;
private int VacationUsedPercent => _settings?.VacationDaysPerYear > 0
? (int)Math.Round(_vacationDays.Count * 100.0 / _settings.VacationDaysPerYear)
: 0;
// Arbeitstage-Konfiguration als Liste von (Label, Getter, Setter)
private static readonly (string Label, Func<AppSettings, bool> Get, Action<AppSettings, bool> Set)[] WorkDayToggles =
[
("Montag", s => s.WorkMonday, (s, v) => s.WorkMonday = v),
("Dienstag", s => s.WorkTuesday, (s, v) => s.WorkTuesday = v),
("Mittwoch", s => s.WorkWednesday, (s, v) => s.WorkWednesday = v),
("Donnerstag", s => s.WorkThursday, (s, v) => s.WorkThursday = v),
("Freitag", s => s.WorkFriday, (s, v) => s.WorkFriday = v),
("Samstag", s => s.WorkSaturday, (s, v) => s.WorkSaturday = v),
("Sonntag", s => s.WorkSunday, (s, v) => s.WorkSunday = v),
];
protected override async Task OnInitializedAsync()
{
_settings = await TrackerService.GetSettingsAsync();
await LoadVacations();
_holHolidays = await HolidayService.GetHolidaysAsync(_holYear);
}
private async Task LoadVacations()
{
_vacationDays = await TrackerService.GetVacationDaysAsync(_vacYear);
}
private async Task ChangeYear(int delta)
{
_vacYear += delta;
await LoadVacations();
}
private async Task Save()
{
if (_settings == null) return;
await TrackerService.SaveSettingsAsync(_settings);
Snackbar.Add("Einstellungen gespeichert", Severity.Success);
}
private async Task AddVacation()
{
if (_newVacDateFrom == null) return;
var from = DateOnly.FromDateTime(_newVacDateFrom.Value);
var to = _newVacDateTo.HasValue ? DateOnly.FromDateTime(_newVacDateTo.Value) : from;
if (to < from) to = from;
var note = string.IsNullOrWhiteSpace(_newVacNote) ? null : _newVacNote.Trim();
var current = from;
var added = 0;
while (current <= to)
{
if (_settings!.IsWorkDay(current.DayOfWeek))
{
await TrackerService.AddVacationDayAsync(new VacationDay { Date = current, Note = note });
added++;
}
current = current.AddDays(1);
}
_newVacDateFrom = null;
_newVacDateTo = null;
_newVacNote = "";
await LoadVacations();
Snackbar.Add(added == 1 ? "Urlaubstag eingetragen" : $"{added} Urlaubstage eingetragen", Severity.Success);
}
private async Task RemoveVacation(int id)
{
await TrackerService.RemoveVacationDayAsync(id);
await LoadVacations();
Snackbar.Add("Urlaubstag entfernt", Severity.Info);
}
// ── Feiertage ────────────────────────────────────────────────
private async Task ChangeHolYear(int delta)
{
_holYear += delta;
_holHolidays = await HolidayService.GetHolidaysAsync(_holYear);
}
private async Task FetchHolidays()
{
_fetchingHolidays = true;
var (success, message) = await HolidayService.FetchAndStoreAsync(_holYear);
_holHolidays = await HolidayService.GetHolidaysAsync(_holYear);
_fetchingHolidays = false;
Snackbar.Add(message, success ? Severity.Success : Severity.Error);
}
private async Task DeleteHoliday(int id)
{
await HolidayService.DeleteAsync(id);
_holHolidays = await HolidayService.GetHolidaysAsync(_holYear);
Snackbar.Add("Feiertag entfernt", Severity.Info);
}
private static string FormatHours(double hours)
{
var ts = TimeSpan.FromHours(hours);
return $"{(int)ts.TotalHours}:{ts.Minutes:D2} h";
}
}
+368
View File
@@ -0,0 +1,368 @@
@page "/urlaub-maximizer"
@rendermode InteractiveServer
@inject TimetrackerService TrackerService
@inject HolidayService HolidayService
@inject ISnackbar Snackbar
<PageTitle>Urlaubs-Maximizer Timetracker</PageTitle>
@if (_loading)
{
<MudStack AlignItems="AlignItems.Center" Class="mt-16" Spacing="3">
<MudProgressCircular Color="Color.Warning" Indeterminate="true" Size="Size.Large" />
<MudText Color="Color.Secondary">Berechne beste Urlaubskombinationen…</MudText>
</MudStack>
}
else
{
<MudStack Spacing="3">
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, #F57F17 0%, #E65100 100%); color:white;">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
Style="color:white" Size="Size.Large" OnClick="PrevYear" />
<MudStack Spacing="0" AlignItems="AlignItems.Center">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.AutoAwesome" Style="color:white" />
<MudText Typo="Typo.h5" Style="color:white; font-weight:700; letter-spacing:0.5px">
Urlaubs-Maximizer @_year
</MudText>
</MudStack>
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.75)">
@_subLabel
</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
@if (_year != DateTime.Today.Year)
{
<MudButton Variant="Variant.Text" Style="color:white"
OnClick="GoToCurrentYear" Size="Size.Small">Heute</MudButton>
}
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
Style="color:white" Size="Size.Large" OnClick="NextYear" />
</MudStack>
</MudStack>
</MudPaper>
@* ── Info-Legende ── *@
<MudPaper Elevation="1" Class="pa-3 rounded-xl">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3" Wrap="Wrap.Wrap">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<div style="width:16px;height:16px;border-radius:4px;background:#3F51B5;"></div>
<MudText Typo="Typo.caption" Color="Color.Secondary">Urlaubstag (einzutragen)</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<div style="width:16px;height:16px;border-radius:4px;background:#009688;"></div>
<MudText Typo="Typo.caption" Color="Color.Secondary">Feiertag</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<div style="width:16px;height:16px;border-radius:4px;background:#FF9800;"></div>
<MudText Typo="Typo.caption" Color="Color.Secondary">Bereits Urlaub</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<div style="width:16px;height:16px;border-radius:4px;background:#ECEFF1;border:1px solid #CFD8DC;"></div>
<MudText Typo="Typo.caption" Color="Color.Secondary">Wochenende / Frei</MudText>
</MudStack>
<MudDivider Vertical="true" FlexItem="true" />
<MudText Typo="Typo.caption" Color="Color.Secondary">
Noch <b>@_remainingDays</b> Urlaubstage verfügbar
</MudText>
</MudStack>
</MudPaper>
@if (!_holidays.Any())
{
<MudAlert Severity="Severity.Info" Class="rounded-xl">
Keine Feiertage für @_year geladen. Gehe zu
<b>Einstellungen → Feiertage</b> und klicke „Von API laden" für optimale Vorschläge.
</MudAlert>
}
@if (_remainingDays <= 0)
{
<MudPaper Elevation="2" Class="pa-8 rounded-xl text-center">
<MudIcon Icon="@Icons.Material.Filled.BeachAccess"
Style="font-size:4rem; color:#FFB74D;" Class="mb-3" />
<MudText Typo="Typo.h6">Alle Urlaubstage sind bereits eingetragen!</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mt-1">
Du hast dein Urlaubskontingent für @_year vollständig verplant.
</MudText>
</MudPaper>
}
else if (_suggestions.Count == 0)
{
<MudPaper Elevation="2" Class="pa-8 rounded-xl text-center">
<MudIcon Icon="@Icons.Material.Filled.SearchOff"
Style="font-size:4rem; color:#CFD8DC;" Class="mb-3" />
<MudText Typo="Typo.h6" Color="Color.Secondary">Keine Vorschläge gefunden</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mt-1">
Für das restliche Jahr @_year sind keine günstigen Brückentag-Kombinationen verfügbar.
</MudText>
</MudPaper>
}
else
{
@* ── Vorschläge gruppiert nach Urlaubstagen ── *@
@foreach (var group in _suggestions.GroupBy(s => s.VacationDaysNeeded).OrderBy(g => g.Key))
{
var dayWord = group.Key == 1 ? "Urlaubstag" : "Urlaubstagen";
var bestEff = group.Max(s => s.Efficiency);
<MudStack Spacing="2">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudText Typo="Typo.overline" Style="font-weight:700; letter-spacing:2px; color:#E65100;">
MIT @group.Key.ToString().ToUpper() @dayWord.ToUpper()
</MudText>
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined"
Style="color:#E65100; border-color:#E65100; height:20px; font-size:11px">
@group.Count() Vorschlag@(group.Count() == 1 ? "" : "schläge")
</MudChip>
</MudStack>
<MudGrid Spacing="3">
@foreach (var s in group.OrderByDescending(x => x.Efficiency))
{
var effColor = s.Efficiency >= 4.0 ? "#4CAF50"
: s.Efficiency >= 3.0 ? "#2196F3"
: s.Efficiency >= 2.0 ? "#FF9800"
: "#9E9E9E";
var effLabel = s.Efficiency >= 4.0 ? "Jackpot"
: s.Efficiency >= 3.0 ? "Sehr gut"
: s.Efficiency >= 2.0 ? "Gut"
: "OK";
<MudItem xs="12" sm="6" lg="4">
<MudCard Elevation="3" Class="rounded-xl h-100"
Style="@($"border-top: 4px solid {effColor};")">
<MudCardContent Class="pa-4">
<MudStack Spacing="3">
@* Titel + Badge *@
<MudStack Row="true" AlignItems="AlignItems.Start"
Justify="Justify.SpaceBetween">
<MudStack Spacing="0">
<MudText Typo="Typo.h5" Style="font-weight:800; line-height:1.1">
@s.TotalFreeDays Tage frei
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">
für @s.VacationDaysNeeded @dayWord
· +@s.BonusDays Bonustage
</MudText>
</MudStack>
<MudStack Spacing="0" AlignItems="AlignItems.End">
<MudText Typo="Typo.h6"
Style="@($"font-weight:800; color:{effColor}")">
@s.Efficiency.ToString("0.0")×
</MudText>
<MudText Typo="Typo.caption"
Style="@($"color:{effColor}; font-weight:600")">
@effLabel
</MudText>
</MudStack>
</MudStack>
@* Datum *@
<MudText Typo="Typo.body2" Color="Color.Secondary">
@s.SpanStart.ToString("ddd, dd. MMM", _deCulture)
&nbsp;&nbsp;
@s.SpanEnd.ToString("ddd, dd. MMM yyyy", _deCulture)
</MudText>
@* Tages-Kacheln *@
<div style="display:flex; flex-wrap:wrap; gap:4px;">
@foreach (var d in DaysInSpan(s))
{
var isVac = s.VacationDaysToTake.Contains(d);
var isHol = _holidays.ContainsKey(d);
var isTaken = _vacationSet.Contains(d);
var bg = isVac ? "#3F51B5"
: isHol ? "#009688"
: isTaken ? "#FF9800"
: "#ECEFF1";
var fg = (isVac || isHol || isTaken) ? "white" : "#607D8B";
var tooltip = isVac ? "Urlaubstag eintragen"
: isHol ? (_holidays.GetValueOrDefault(d) ?? "Feiertag")
: isTaken ? "Bereits Urlaub"
: "Wochenende";
<MudTooltip Text="@tooltip">
<div style="@($"display:flex;flex-direction:column;align-items:center;justify-content:center;width:36px;height:44px;border-radius:8px;background:{bg};color:{fg};")">
<span style="font-size:0.55rem;font-weight:700;letter-spacing:0.5px;line-height:1.2">
@d.ToString("ddd", _deCulture).Substring(0, 2).ToUpper()
</span>
<span style="font-size:0.85rem;font-weight:700;line-height:1.2">
@d.Day
</span>
</div>
</MudTooltip>
}
</div>
</MudStack>
</MudCardContent>
<MudCardActions Class="pa-3 pt-0">
<MudButton Variant="Variant.Filled"
StartIcon="@Icons.Material.Filled.BeachAccess"
Style="@($"background:{effColor}; color:white;")"
Size="Size.Small" FullWidth="true"
OnClick="@(async () => await TakeSuggestion(s))">
Urlaub eintragen
</MudButton>
</MudCardActions>
</MudCard>
</MudItem>
}
</MudGrid>
</MudStack>
}
}
</MudStack>
}
@code {
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
private bool _loading = true;
private int _year = DateTime.Today.Year;
private AppSettings _settings = new();
private Dictionary<DateOnly, string> _holidays = [];
private HashSet<DateOnly> _vacationSet = [];
private int _remainingDays;
private List<Suggestion> _suggestions = [];
private string _subLabel = "";
protected override async Task OnInitializedAsync()
{
_settings = await TrackerService.GetSettingsAsync();
await LoadYear();
_loading = false;
}
private async Task LoadYear()
{
var holidays = await HolidayService.GetHolidaysAsync(_year);
var vacations = await TrackerService.GetVacationDaysAsync(_year);
_holidays = holidays.ToDictionary(h => h.Date, h => h.Name);
_vacationSet = vacations.Select(v => v.Date).ToHashSet();
_remainingDays = Math.Max(0, _settings.VacationDaysPerYear - vacations.Count);
_suggestions = ComputeSuggestions();
var count = _suggestions.Count;
var bestEff = count > 0 ? _suggestions.Max(s => s.Efficiency) : 0;
_subLabel = count == 0
? "Keine Vorschläge verfügbar"
: $"{count} Kombination{(count == 1 ? "" : "en")} · Beste: {bestEff:0.0}× Effizienz";
}
private async Task PrevYear() { _year--; await LoadYear(); }
private async Task NextYear() { _year++; await LoadYear(); }
private async Task GoToCurrentYear() { _year = DateTime.Today.Year; await LoadYear(); }
private async Task TakeSuggestion(Suggestion s)
{
foreach (var d in s.VacationDaysToTake.Where(d => !_vacationSet.Contains(d)))
await TrackerService.AddVacationDayAsync(new VacationDay { Date = d, Note = "Urlaubs-Maximizer" });
await LoadYear();
var word = s.VacationDaysNeeded == 1 ? "Urlaubstag" : "Urlaubstage";
Snackbar.Add($"{s.VacationDaysNeeded} {word} eingetragen {s.TotalFreeDays} Tage frei!", Severity.Success);
}
// ── Algorithmus ──────────────────────────────────────────────────────
private enum DayKind { Free, WorkAvailable, WorkTaken }
private sealed record Suggestion(
DateOnly SpanStart,
DateOnly SpanEnd,
List<DateOnly> VacationDaysToTake,
int VacationDaysNeeded,
int TotalFreeDays,
double Efficiency)
{
public int BonusDays => TotalFreeDays - VacationDaysNeeded;
}
private List<Suggestion> ComputeSuggestions()
{
var startOfYear = new DateOnly(_year, 1, 1);
var endOfYear = new DateOnly(_year, 12, 31);
var today = DateOnly.FromDateTime(DateTime.Today);
int n = endOfYear.DayNumber - startOfYear.DayNumber + 1;
// Classify each day
var kinds = new DayKind[n];
for (int i = 0; i < n; i++)
{
var d = startOfYear.AddDays(i);
if (!_settings.IsWorkDay(d.DayOfWeek) || _holidays.ContainsKey(d))
kinds[i] = DayKind.Free;
else if (_vacationSet.Contains(d))
kinds[i] = DayKind.WorkTaken;
else
kinds[i] = DayKind.WorkAvailable;
}
// Prefix sum: count of WorkAvailable days
var psum = new int[n + 1];
for (int i = 0; i < n; i++)
psum[i + 1] = psum[i] + (kinds[i] == DayKind.WorkAvailable ? 1 : 0);
int CountAvail(int a, int b) => psum[b + 1] - psum[a];
int maxVac = Math.Min(_remainingDays, 5);
if (maxVac <= 0) return [];
// For each window [ws, we] of contiguous days, compute best span
var best = new Dictionary<(int, int), Suggestion>();
for (int ws = 0; ws < n; ws++)
{
if (kinds[ws] != DayKind.WorkAvailable) continue;
for (int we = ws; we < n; we++)
{
int vac = CountAvail(ws, we);
if (vac > maxVac) break;
if (vac == 0) continue;
// Extend span outward through non-WorkAvailable days
int ss = ws, se = we;
while (ss > 0 && kinds[ss - 1] != DayKind.WorkAvailable) ss--;
while (se < n - 1 && kinds[se + 1] != DayKind.WorkAvailable) se++;
int total = se - ss + 1;
if (total <= vac) continue; // No bonus days → skip
if (startOfYear.AddDays(se) < today) continue; // Fully in the past → skip
double eff = (double)total / vac;
var key = (ss, se);
var vacDays = Enumerable.Range(ws, we - ws + 1)
.Where(i => kinds[i] == DayKind.WorkAvailable)
.Select(i => startOfYear.AddDays(i))
.ToList();
var sug = new Suggestion(
startOfYear.AddDays(ss), startOfYear.AddDays(se),
vacDays, vac, total, eff);
if (!best.TryGetValue(key, out var existing) || vac < existing.VacationDaysNeeded)
best[key] = sug;
}
}
// Group by vac days needed, keep top 4 per group by efficiency
return [.. best.Values
.GroupBy(s => s.VacationDaysNeeded)
.OrderBy(g => g.Key)
.SelectMany(g => g
.OrderByDescending(s => s.Efficiency)
.ThenByDescending(s => s.TotalFreeDays)
.Take(4))];
}
private static IEnumerable<DateOnly> DaysInSpan(Suggestion s)
{
for (var d = s.SpanStart; d <= s.SpanEnd; d = d.AddDays(1))
yield return d;
}
}