Files
timetracker/Components/Pages/UrlaubsMaximizer.razor
T
2026-06-07 23:36:45 +02:00

376 lines
19 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 "/urlaub-maximizer"
@rendermode InteractiveServer
@attribute [Authorize]
@inject TimetrackerService TrackerService
@inject HolidayService HolidayService
@inject ISnackbar Snackbar
@inject AuthenticationStateProvider AuthStateProvider
<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 = "";
private int _userId;
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return;
_userId = int.Parse(claim.Value);
_settings = await TrackerService.GetSettingsAsync(_userId);
await LoadYear();
_loading = false;
}
private async Task LoadYear()
{
var holidays = await HolidayService.GetHolidaysAsync(_year, _settings.GermanState);
var vacations = await TrackerService.GetVacationDaysAsync(_userId, _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 { UserId = _userId, 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;
}
}