WASM Mode activated
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
@page "/admin/users"
|
||||
@rendermode InteractiveWebAssembly
|
||||
@attribute [Authorize(Policy = "AdminOnly")]
|
||||
@inject IAuthService AuthService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject IUserNotificationService UserNotificationService
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>Benutzerverwaltung – 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 Benutzer…</MudText>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Spacing="4">
|
||||
|
||||
@* ── Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
||||
Style="background: linear-gradient(135deg, #B71C1C 0%, #7F0000 100%); color:white;">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AdminPanelSettings" Style="color:white; font-size:2rem" />
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.h5" Style="color:white; font-weight:700">Benutzerverwaltung</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
|
||||
@_users.Count Benutzer registriert
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@* ── Tabelle ── *@
|
||||
<MudCard Elevation="3" Class="rounded-xl">
|
||||
<MudCardContent Class="pa-0">
|
||||
<MudTable Items="_users" Hover="true" Striped="true" Dense="false"
|
||||
SortLabel="Sortieren">
|
||||
<HeaderContent>
|
||||
<MudTh><MudTableSortLabel SortBy="new Func<User, object>(u => u.Id)">ID</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortBy="new Func<User, object>(u => u.Username)" InitialDirection="SortDirection.Ascending">Benutzername</MudTableSortLabel></MudTh>
|
||||
<MudTh Style="text-align:right">Aktionen</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@context.Id</MudText>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
@if (_editUserId == context.Id)
|
||||
{
|
||||
<MudTextField @bind-Value="_editUsername"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense"
|
||||
Immediate="true"
|
||||
Style="max-width:220px"
|
||||
OnKeyDown="@(async e => { if (e.Key == "Enter") await SaveRename(context); })" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AccountCircle"
|
||||
Color="@(context.Username == "marc" ? Color.Error : Color.Default)"
|
||||
Size="Size.Small" />
|
||||
<MudText Style="@(context.Username == "marc" ? "font-weight:700" : "")">
|
||||
@context.Username
|
||||
</MudText>
|
||||
@if (context.Username == "marc")
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Error" Variant="Variant.Outlined">Admin</MudChip>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd Style="text-align:right">
|
||||
@if (_editUserId == context.Id)
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Check"
|
||||
Color="Color.Success"
|
||||
Size="Size.Small"
|
||||
OnClick="@(() => SaveRename(context))" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Close"
|
||||
Color="Color.Default"
|
||||
Size="Size.Small"
|
||||
OnClick="CancelEdit" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small"
|
||||
OnClick="@(() => StartEdit(context))" />
|
||||
@if (context.Username != "marc")
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline"
|
||||
Color="Color.Error"
|
||||
Size="Size.Small"
|
||||
OnClick="@(() => DeleteUser(context))" />
|
||||
}
|
||||
}
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<NoRecordsContent>
|
||||
<MudText Color="Color.Secondary" Class="pa-4">Keine Benutzer gefunden.</MudText>
|
||||
</NoRecordsContent>
|
||||
</MudTable>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<User> _users = [];
|
||||
private bool _loading = true;
|
||||
private int? _editUserId;
|
||||
private string _editUsername = "";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var claim = (await AuthStateProvider.GetAuthenticationStateAsync())
|
||||
.User.FindFirst(ClaimTypes.NameIdentifier);
|
||||
if (claim == null) return;
|
||||
|
||||
UserNotificationService.OnUsersChanged += RefreshUsers;
|
||||
_users = await AuthService.GetAllUsersAsync();
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task RefreshUsers()
|
||||
{
|
||||
_users = await AuthService.GetAllUsersAsync();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
UserNotificationService.OnUsersChanged -= RefreshUsers;
|
||||
}
|
||||
|
||||
private void StartEdit(User user)
|
||||
{
|
||||
_editUserId = user.Id;
|
||||
_editUsername = user.Username;
|
||||
}
|
||||
|
||||
private void CancelEdit()
|
||||
{
|
||||
_editUserId = null;
|
||||
_editUsername = "";
|
||||
}
|
||||
|
||||
private async Task SaveRename(User user)
|
||||
{
|
||||
var trimmed = _editUsername.Trim();
|
||||
var error = await AuthService.RenameUserAsync(user.Id, trimmed);
|
||||
if (error != null)
|
||||
{
|
||||
Snackbar.Add(error, Severity.Error);
|
||||
return;
|
||||
}
|
||||
user.Username = trimmed;
|
||||
_editUserId = null;
|
||||
_editUsername = "";
|
||||
Snackbar.Add($"Benutzer umbenannt zu \"{trimmed}\".", Severity.Success);
|
||||
}
|
||||
|
||||
private async Task DeleteUser(User user)
|
||||
{
|
||||
await AuthService.DeleteUserAsync(user.Id);
|
||||
_users.Remove(user);
|
||||
Snackbar.Add($"Benutzer \"{user.Username}\" gelöscht.", Severity.Info);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
@page "/changelog"
|
||||
@rendermode InteractiveWebAssembly
|
||||
@attribute [Authorize]
|
||||
|
||||
<PageTitle>Changelog – Timetracker</PageTitle>
|
||||
|
||||
<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.NewReleases" Style="color:white; font-size:2rem" />
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.h5" Style="color:white; font-weight:700">Changelog</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">Versionshistorie & Änderungen</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@foreach (var release in _releases)
|
||||
{
|
||||
<MudCard Elevation="2" Class="rounded-xl">
|
||||
<MudCardContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-3">
|
||||
<MudChip T="string" Color="@(release.IsLatest ? Color.Primary : Color.Default)"
|
||||
Variant="Variant.Filled" Size="Size.Medium" Style="font-weight:700">
|
||||
@release.Version
|
||||
</MudChip>
|
||||
@if (release.IsLatest)
|
||||
{
|
||||
<MudChip T="string" Color="Color.Success" Variant="Variant.Outlined" Size="Size.Small">
|
||||
Aktuell
|
||||
</MudChip>
|
||||
}
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@release.Date</MudText>
|
||||
</MudStack>
|
||||
<MudDivider Class="mb-3" />
|
||||
<MudStack Spacing="1">
|
||||
@foreach (var entry in release.Entries)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Start" Spacing="2">
|
||||
<MudChip T="string" Color="@GetTagColor(entry.Tag)" Variant="Variant.Outlined"
|
||||
Size="Size.Small" Style="min-width:72px; justify-content:center; font-size:0.7rem;">
|
||||
@entry.Tag
|
||||
</MudChip>
|
||||
<MudText Typo="Typo.body2" Style="padding-top:2px">@entry.Text</MudText>
|
||||
</MudStack>
|
||||
}
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
}
|
||||
|
||||
</MudStack>
|
||||
|
||||
@code {
|
||||
private record ChangeEntry(string Tag, string Text);
|
||||
private record Release(string Version, string Date, bool IsLatest, List<ChangeEntry> Entries);
|
||||
|
||||
private static Color GetTagColor(string tag) => tag switch
|
||||
{
|
||||
"Neu" => Color.Success,
|
||||
"Fix" => Color.Error,
|
||||
"Upgrade" => Color.Info,
|
||||
_ => Color.Default
|
||||
};
|
||||
|
||||
private readonly List<Release> _releases =
|
||||
[
|
||||
new("1.2", "08.06.2026", true,
|
||||
[
|
||||
new("Upgrade", "Architektur-Migration auf Hosted Blazor WebAssembly (.NET 10) mit sauberer Projektstruktur (Client, Server, Shared)"),
|
||||
new("Neu", "Unterstützung für PostgreSQL-Datenbanken als produktive und skalierbare Alternative zu SQLite"),
|
||||
new("Neu", "Dynamische DB-Provider-Weiche (SQLite vs. PostgreSQL) über Konfigurations- und Umgebungsvariablen"),
|
||||
new("Neu", "Docker-Compose-Konfiguration inklusive PostgreSQL-Container für vereinfachten Deployment-Betrieb"),
|
||||
new("Neu", "Lokales Ausführungsskript (run-local.sh) für einfaches Testen auf dem Entwicklungsrechner"),
|
||||
]),
|
||||
new("1.1", "08.06.2026", false,
|
||||
[
|
||||
new("Neu", "Versionsnummer in der Navbar mit Link zum Changelog"),
|
||||
new("Neu", "Changelog-Seite"),
|
||||
new("Neu", "Live-Aktualisierung der Benutzerliste bei neuer Registrierung"),
|
||||
new("Neu", "Automatisches Abmelden gelöschter Benutzer"),
|
||||
new("Neu", "Benutzernamen in der Benutzerverwaltung umbenennen"),
|
||||
new("Upgrade", "Navbar: Benutzer und Abmelden-Button unten fixiert"),
|
||||
]),
|
||||
new("1.0", "20.05.2026", false,
|
||||
[
|
||||
new("Neu", "Erste Version des Timetrackers"),
|
||||
new("Neu", "Wochenübersicht mit Arbeitszeiten und Pausen"),
|
||||
new("Neu", "Monatsübersicht"),
|
||||
new("Neu", "Feiertage-Verwaltung"),
|
||||
new("Neu", "Urlaubs-Maximizer"),
|
||||
new("Neu", "Einstellungen"),
|
||||
new("Neu", "Benutzerverwaltung für Admins"),
|
||||
new("Neu", "Registrierung und Login"),
|
||||
]),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
@page "/feiertage"
|
||||
@rendermode InteractiveWebAssembly
|
||||
@attribute [Authorize]
|
||||
@inject IHolidayService HolidayService
|
||||
@inject ITimetrackerService TrackerService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
|
||||
<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 = "";
|
||||
|
||||
private int _userId;
|
||||
private AppSettings _settings = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var claim = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
|
||||
if (claim != null)
|
||||
{
|
||||
_userId = int.Parse(claim.Value);
|
||||
_settings = await TrackerService.GetSettingsAsync(_userId);
|
||||
}
|
||||
await LoadHolidays();
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task LoadHolidays()
|
||||
{
|
||||
_holidays = await HolidayService.GetHolidaysAsync(_year, _settings.GermanState);
|
||||
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(); }
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
@page "/"
|
||||
@rendermode InteractiveWebAssembly
|
||||
@attribute [Authorize]
|
||||
@inject ITimetrackerService TrackerService
|
||||
@inject IHolidayService HolidayService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
|
||||
<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 int _userId;
|
||||
private DateOnly _monday;
|
||||
private List<DayVm> _days = [];
|
||||
private AppSettings _settings = new();
|
||||
private string _weekLabel = "";
|
||||
private string _weekSubLabel = "";
|
||||
private int _kw => _monday == default ? 0 : _deCulture.Calendar.GetWeekOfYear(
|
||||
_monday.ToDateTime(TimeOnly.MinValue),
|
||||
System.Globalization.CalendarWeekRule.FirstFourDayWeek,
|
||||
DayOfWeek.Monday);
|
||||
private TimeSpan _totalOvertime;
|
||||
private Dictionary<DateOnly, string> _holidays = [];
|
||||
private int _holidayYear = -1;
|
||||
|
||||
private bool IsCurrentWeek => _monday == GetMonday(DateOnly.FromDateTime(DateTime.Today));
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
|
||||
if (claim == null) return; // Prerender-Pass – Circuit noch nicht authentifiziert
|
||||
_userId = int.Parse(claim.Value);
|
||||
_monday = GetMonday(DateOnly.FromDateTime(DateTime.Today));
|
||||
_settings = await TrackerService.GetSettingsAsync(_userId);
|
||||
await LoadWeek();
|
||||
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task LoadWeek()
|
||||
{
|
||||
if (_monday.Year != _holidayYear)
|
||||
{
|
||||
var list = await HolidayService.GetHolidaysAsync(_monday.Year, _settings.GermanState);
|
||||
_holidays = list.ToDictionary(h => h.Date, h => h.Name);
|
||||
_holidayYear = _monday.Year;
|
||||
}
|
||||
var dbDays = await TrackerService.GetWeekAsync(_userId, _monday);
|
||||
_days = Enumerable.Range(0, 7).Select(i =>
|
||||
{
|
||||
var date = _monday.AddDays(i);
|
||||
return DayVm.From(dbDays.FirstOrDefault(d => d.Date == date), date, _userId);
|
||||
}).ToList();
|
||||
BuildWeekLabels();
|
||||
}
|
||||
|
||||
private void BuildWeekLabels()
|
||||
{
|
||||
var sunday = _monday.AddDays(6);
|
||||
var kw = _deCulture.Calendar.GetWeekOfYear(
|
||||
_monday.ToDateTime(TimeOnly.MinValue),
|
||||
System.Globalization.CalendarWeekRule.FirstFourDayWeek,
|
||||
DayOfWeek.Monday);
|
||||
_weekLabel = $"KW {kw} · {_monday:dd. MMM} – {sunday:dd. MMM yyyy}";
|
||||
var recorded = _days.Count(d => d.Start.HasValue || d.End.HasValue);
|
||||
var workDays = _days.Count(d => _settings.IsWorkDay(d.Date.DayOfWeek));
|
||||
_weekSubLabel = recorded == 0
|
||||
? "Noch keine Einträge diese Woche"
|
||||
: $"{recorded} von {workDays} Arbeitstagen erfasst";
|
||||
}
|
||||
|
||||
// ── Navigation ──────────────────────────────────────────────
|
||||
private async Task PrevWeek() { _monday = _monday.AddDays(-7); await LoadWeek(); }
|
||||
private async Task NextWeek() { _monday = _monday.AddDays(7); await LoadWeek(); }
|
||||
private async Task GoToCurrentWeek() { _monday = GetMonday(DateOnly.FromDateTime(DateTime.Today)); await LoadWeek(); }
|
||||
|
||||
private static DateOnly GetMonday(DateOnly date)
|
||||
{
|
||||
int diff = ((int)date.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7;
|
||||
return date.AddDays(-diff);
|
||||
}
|
||||
|
||||
// ── Change Handlers ──────────────────────────────────────────
|
||||
private async Task OnStartChanged(DayVm day, TimeSpan? v) { day.Start = v; await SaveDay(day); }
|
||||
private async Task OnEndChanged(DayVm day, TimeSpan? v) { day.End = v; await SaveDay(day); }
|
||||
|
||||
private async Task OnBreakStartChanged(DayVm day, int idx, TimeSpan? v)
|
||||
{ day.Breaks[idx].Start = v; await SaveDay(day); }
|
||||
|
||||
private async Task OnBreakEndChanged(DayVm day, int idx, TimeSpan? v)
|
||||
{ day.Breaks[idx].End = v; await SaveDay(day); }
|
||||
|
||||
private void AddBreak(DayVm day)
|
||||
{
|
||||
day.Breaks.Add(new BreakVm());
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task RemoveBreak(DayVm day, int idx)
|
||||
{ day.Breaks.RemoveAt(idx); await SaveDay(day); }
|
||||
|
||||
private async Task SaveDay(DayVm day)
|
||||
{
|
||||
await TrackerService.UpsertWorkDayAsync(day.ToWorkDay());
|
||||
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
|
||||
BuildWeekLabels();
|
||||
}
|
||||
|
||||
// ── Berechnungen ─────────────────────────────────────────────
|
||||
private TimeSpan? 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 int UserId { get; set; }
|
||||
public DateOnly Date { get; set; }
|
||||
public TimeSpan? Start { get; set; }
|
||||
public TimeSpan? End { get; set; }
|
||||
public List<BreakVm> Breaks { get; set; } = [];
|
||||
|
||||
public TimeSpan? GrossWork =>
|
||||
Start.HasValue && End.HasValue && End > Start ? End.Value - Start.Value : null;
|
||||
|
||||
public TimeSpan TotalBreakTime => Breaks
|
||||
.Where(b => b.Duration.HasValue)
|
||||
.Aggregate(TimeSpan.Zero, (s, b) => s + b.Duration!.Value);
|
||||
|
||||
public TimeSpan? NetWork => GrossWork.HasValue ? GrossWork.Value - TotalBreakTime : null;
|
||||
|
||||
public static DayVm From(WorkDay? wd, DateOnly date, int userId) => new()
|
||||
{
|
||||
Id = wd?.Id ?? 0,
|
||||
UserId = wd?.UserId ?? userId,
|
||||
Date = date,
|
||||
Start = wd?.StartTime?.ToTimeSpan(),
|
||||
End = wd?.EndTime?.ToTimeSpan(),
|
||||
Breaks = wd?.Breaks.Select(b => new BreakVm
|
||||
{
|
||||
Id = b.Id,
|
||||
Start = b.StartTime?.ToTimeSpan(),
|
||||
End = b.EndTime?.ToTimeSpan()
|
||||
}).ToList() ?? []
|
||||
};
|
||||
|
||||
public WorkDay ToWorkDay() => new()
|
||||
{
|
||||
Id = Id,
|
||||
UserId = UserId,
|
||||
Date = Date,
|
||||
StartTime = Start.HasValue ? TimeOnly.FromTimeSpan(Start.Value) : null,
|
||||
EndTime = End.HasValue ? TimeOnly.FromTimeSpan(End.Value) : null,
|
||||
Breaks = Breaks.Select(b => new BreakEntry
|
||||
{
|
||||
Id = b.Id,
|
||||
StartTime = b.Start.HasValue ? TimeOnly.FromTimeSpan(b.Start.Value) : null,
|
||||
EndTime = b.End.HasValue ? TimeOnly.FromTimeSpan(b.End.Value) : null
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class BreakVm
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public TimeSpan? Start { get; set; }
|
||||
public TimeSpan? End { get; set; }
|
||||
public TimeSpan? Duration =>
|
||||
Start.HasValue && End.HasValue && End > Start ? End.Value - Start.Value : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
@page "/login"
|
||||
@rendermode InteractiveWebAssembly
|
||||
@attribute [AllowAnonymous]
|
||||
@inject IAuthService AuthService
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<PageTitle>Anmelden – Timetracker</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Small" Class="mt-16">
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="4">
|
||||
|
||||
@* ── Logo / Header ── *@
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AccessTime"
|
||||
Style="font-size:4rem; color:#1565C0" />
|
||||
<MudText Typo="Typo.h4" Style="font-weight:700; color:#1565C0">Timetracker</MudText>
|
||||
</MudStack>
|
||||
|
||||
<MudPaper Elevation="4" Class="pa-6 rounded-xl" Style="width:100%">
|
||||
@* ── Tab Navigation ── *@
|
||||
<MudStack Row="true" Justify="Justify.Center" Class="mb-4">
|
||||
<MudButton OnClick="@(() => SetTab(0))"
|
||||
Variant="@(_activeTab == 0 ? Variant.Filled : Variant.Text)"
|
||||
Color="Color.Primary"
|
||||
Style="min-width: 120px; border-radius: 20px;">
|
||||
Anmelden
|
||||
</MudButton>
|
||||
<MudButton OnClick="@(() => SetTab(1))"
|
||||
Variant="@(_activeTab == 1 ? Variant.Filled : Variant.Text)"
|
||||
Color="Color.Primary"
|
||||
Style="min-width: 120px; border-radius: 20px;">
|
||||
Registrieren
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
<MudDivider Class="mb-6" />
|
||||
|
||||
@if (_activeTab == 0)
|
||||
{
|
||||
@* ── Login Form ── *@
|
||||
<MudStack Spacing="3">
|
||||
@if (_error != null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
|
||||
}
|
||||
<EditForm Model="@_loginModel" OnValidSubmit="HandleLogin">
|
||||
<MudStack Spacing="3">
|
||||
<MudTextField T="string"
|
||||
Label="Benutzername"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Person"
|
||||
@bind-Value="_loginModel.Username"
|
||||
Required="true"
|
||||
AutoFocus="true" />
|
||||
<MudTextField T="string"
|
||||
Label="Passwort"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Lock"
|
||||
InputType="InputType.Password"
|
||||
@bind-Value="_loginModel.Password"
|
||||
Required="true" />
|
||||
<MudButton ButtonType="ButtonType.Submit"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
FullWidth="true"
|
||||
Size="Size.Large"
|
||||
StartIcon="@Icons.Material.Filled.Login"
|
||||
Class="mt-2"
|
||||
Disabled="_loading">
|
||||
@if (_loading)
|
||||
{
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
|
||||
}
|
||||
Anmelden
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</EditForm>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ── Register Form ── *@
|
||||
<MudStack Spacing="3">
|
||||
@if (_error != null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
|
||||
}
|
||||
<EditForm Model="@_registerModel" OnValidSubmit="HandleRegister">
|
||||
<MudStack Spacing="3">
|
||||
<MudTextField T="string"
|
||||
Label="Benutzername"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Person"
|
||||
@bind-Value="_registerModel.Username"
|
||||
Required="true"
|
||||
HelperText="Mindestens 3 Zeichen" />
|
||||
<MudTextField T="string"
|
||||
Label="Passwort"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Lock"
|
||||
InputType="InputType.Password"
|
||||
@bind-Value="_registerModel.Password"
|
||||
Required="true"
|
||||
HelperText="Mindestens 6 Zeichen" />
|
||||
<MudButton ButtonType="ButtonType.Submit"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Secondary"
|
||||
FullWidth="true"
|
||||
Size="Size.Large"
|
||||
StartIcon="@Icons.Material.Filled.PersonAdd"
|
||||
Class="mt-2"
|
||||
Disabled="_loading">
|
||||
@if (_loading)
|
||||
{
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
|
||||
}
|
||||
Konto erstellen
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</EditForm>
|
||||
</MudStack>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
private int _activeTab = 0;
|
||||
private string? _error;
|
||||
private bool _loading;
|
||||
|
||||
private readonly AuthModel _loginModel = new();
|
||||
private readonly AuthModel _registerModel = new();
|
||||
|
||||
[SupplyParameterFromQuery(Name = "error")]
|
||||
public string? ErrorParam { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery(Name = "tab")]
|
||||
public string? TabParam { get; set; }
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_error = ErrorParam switch
|
||||
{
|
||||
"invalid" => "Benutzername oder Passwort falsch.",
|
||||
not null => Uri.UnescapeDataString(ErrorParam),
|
||||
_ => null
|
||||
};
|
||||
_activeTab = TabParam == "register" ? 1 : 0;
|
||||
}
|
||||
|
||||
private void SetTab(int tab)
|
||||
{
|
||||
_activeTab = tab;
|
||||
_error = null;
|
||||
}
|
||||
|
||||
private async Task HandleLogin()
|
||||
{
|
||||
_loading = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
var user = await AuthService.LoginAsync(_loginModel.Username, _loginModel.Password);
|
||||
if (user != null)
|
||||
{
|
||||
Nav.NavigateTo("/", forceLoad: true); // forceLoad forces state update/re-render of the root app
|
||||
}
|
||||
else
|
||||
{
|
||||
_error = "Benutzername oder Passwort falsch.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = $"Login Fehler: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleRegister()
|
||||
{
|
||||
_loading = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
var (user, error) = await AuthService.RegisterAsync(_registerModel.Username, _registerModel.Password);
|
||||
if (user != null)
|
||||
{
|
||||
Nav.NavigateTo("/", forceLoad: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
_error = error ?? "Registrierung fehlgeschlagen.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = $"Registrierungs Fehler: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private class AuthModel
|
||||
{
|
||||
public string Username { get; set; } = "";
|
||||
public string Password { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
@page "/month"
|
||||
@rendermode InteractiveWebAssembly
|
||||
@attribute [Authorize]
|
||||
@inject ITimetrackerService TrackerService
|
||||
@inject IHolidayService HolidayService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
|
||||
<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 _userId;
|
||||
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()
|
||||
{
|
||||
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 LoadMonth();
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task LoadMonth()
|
||||
{
|
||||
var workDays = await TrackerService.GetMonthAsync(_userId, _year, _month);
|
||||
var holidays = await HolidayService.GetHolidaysAsync(_year, _settings.GermanState);
|
||||
var vacations = await TrackerService.GetVacationDaysAsync(_userId, _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; }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,583 @@
|
||||
@page "/settings"
|
||||
@rendermode InteractiveWebAssembly
|
||||
@attribute [Authorize]
|
||||
@inject ITimetrackerService TrackerService
|
||||
@inject IHolidayService HolidayService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
|
||||
<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>
|
||||
|
||||
@* ── Region & Feiertage ── *@
|
||||
<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.Public" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.h6" Style="font-weight:600">Region & Feiertage</MudText>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudStack Spacing="4">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
Wähle dein Bundesland aus, um Bundesland-spezifische Feiertage zu berücksichtigen.
|
||||
</MudText>
|
||||
<MudSelect T="string" Label="Bundesland" Variant="Variant.Outlined" @bind-Value="_settings.GermanState" Clearable="true" Placeholder="Nur bundesweite Feiertage">
|
||||
@foreach (var state in GermanStates)
|
||||
{
|
||||
<MudSelectItem Value="@state.Key">@state.Value</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* ── Gleitzeitkonto ── *@
|
||||
<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.AccountBalance" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.h6" Style="font-weight:600">Gleitzeitkonto-Start</MudText>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudStack Spacing="4">
|
||||
<MudDatePicker Label="Berechnungsstart"
|
||||
@bind-Date="_flexStartDate"
|
||||
HelperText="Gleitzeitberechnung läuft ab diesem Datum. Wenn leer, ab dem ersten Arbeitseintrag."
|
||||
Variant="Variant.Outlined"
|
||||
Clearable="true"
|
||||
DateFormat="dd.MM.yyyy"
|
||||
PickerVariant="PickerVariant.Inline" />
|
||||
|
||||
<MudNumericField @bind-Value="_settings.FlexTimeStartingBalanceHours"
|
||||
Label="Anfangsüberstunden (h)"
|
||||
Variant="Variant.Outlined"
|
||||
Step="0.5"
|
||||
Format="0.##"
|
||||
HelperText="Stufensaldo (Guthaben/Schulden) zum Berechnungsstart" />
|
||||
</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ü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 _userId;
|
||||
private DateTime? _flexStartDate
|
||||
{
|
||||
get => _settings?.FlexTimeStartDate?.ToDateTime(TimeOnly.MinValue);
|
||||
set => _settings!.FlexTimeStartDate = value.HasValue ? DateOnly.FromDateTime(value.Value) : null;
|
||||
}
|
||||
|
||||
private static readonly Dictionary<string, string> GermanStates = new()
|
||||
{
|
||||
{ "DE-BW", "Baden-Württemberg" },
|
||||
{ "DE-BY", "Bayern" },
|
||||
{ "DE-BE", "Berlin" },
|
||||
{ "DE-BB", "Brandenburg" },
|
||||
{ "DE-HB", "Bremen" },
|
||||
{ "DE-HH", "Hamburg" },
|
||||
{ "DE-HE", "Hessen" },
|
||||
{ "DE-MV", "Mecklenburg-Vorpommern" },
|
||||
{ "DE-NI", "Niedersachsen" },
|
||||
{ "DE-NW", "Nordrhein-Westfalen" },
|
||||
{ "DE-RP", "Rheinland-Pfalz" },
|
||||
{ "DE-SL", "Saarland" },
|
||||
{ "DE-SN", "Sachsen" },
|
||||
{ "DE-ST", "Sachsen-Anhalt" },
|
||||
{ "DE-SH", "Schleswig-Holstein" },
|
||||
{ "DE-TH", "Thüringen" }
|
||||
};
|
||||
|
||||
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()
|
||||
{
|
||||
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 LoadVacations();
|
||||
_holHolidays = await HolidayService.GetHolidaysAsync(_holYear, _settings.GermanState);
|
||||
}
|
||||
|
||||
private async Task LoadVacations()
|
||||
{
|
||||
_vacationDays = await TrackerService.GetVacationDaysAsync(_userId, _vacYear);
|
||||
}
|
||||
|
||||
private async Task ChangeYear(int delta)
|
||||
{
|
||||
_vacYear += delta;
|
||||
await LoadVacations();
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
if (_settings == null) return;
|
||||
await TrackerService.SaveSettingsAsync(_settings);
|
||||
// Nach dem Speichern Feiertage neu laden, falls sich das Bundesland geändert hat
|
||||
_holHolidays = await HolidayService.GetHolidaysAsync(_holYear, _settings.GermanState);
|
||||
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 { UserId = _userId, 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(_userId, id);
|
||||
await LoadVacations();
|
||||
Snackbar.Add("Urlaubstag entfernt", Severity.Info);
|
||||
}
|
||||
|
||||
// ── Feiertage ────────────────────────────────────────────────
|
||||
private async Task ChangeHolYear(int delta)
|
||||
{
|
||||
_holYear += delta;
|
||||
_holHolidays = await HolidayService.GetHolidaysAsync(_holYear, _settings?.GermanState);
|
||||
}
|
||||
|
||||
private async Task FetchHolidays()
|
||||
{
|
||||
_fetchingHolidays = true;
|
||||
var (success, message) = await HolidayService.FetchAndStoreAsync(_holYear);
|
||||
_holHolidays = await HolidayService.GetHolidaysAsync(_holYear, _settings?.GermanState);
|
||||
_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, _settings?.GermanState);
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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