WASM Mode activated
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user