376 lines
19 KiB
Plaintext
376 lines
19 KiB
Plaintext
@page "/urlaub-maximizer"
|
||
@rendermode InteractiveWebAssembly
|
||
@attribute [Authorize]
|
||
@inject ITimetrackerService TrackerService
|
||
@inject IHolidayService 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)
|
||
–
|
||
@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;
|
||
}
|
||
}
|