WASM Mode activated
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
@inherits LayoutComponentBase
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Nav
|
||||
@inject IUserNotificationService UserNotificationService
|
||||
@implements IDisposable
|
||||
@using System.Security.Claims
|
||||
|
||||
<MudThemeProvider Theme="_theme" />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
<MudLayout>
|
||||
<MudAppBar Elevation="2" Style="background: linear-gradient(90deg, #3F51B5 0%, #1A237E 100%);">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" OnClick="ToggleDrawer" />
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="ml-2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AccessTime" Style="color:white; font-size:1.6rem" />
|
||||
<MudText Typo="Typo.h6" Style="color:white; font-weight:700; letter-spacing:0.5px">Timetracker</MudText>
|
||||
</MudStack>
|
||||
<MudSpacer />
|
||||
</MudAppBar>
|
||||
|
||||
<MudDrawer @bind-Open="_drawerOpen" ClipMode="DrawerClipMode.Always" Elevation="2">
|
||||
<NavMenu />
|
||||
</MudDrawer>
|
||||
|
||||
<MudMainContent>
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4 pb-8">
|
||||
@Body
|
||||
</MudContainer>
|
||||
</MudMainContent>
|
||||
</MudLayout>
|
||||
|
||||
@code {
|
||||
private bool _drawerOpen = true;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
UserNotificationService.OnUserDeleted += HandleUserDeleted;
|
||||
}
|
||||
|
||||
private async Task HandleUserDeleted(int deletedUserId)
|
||||
{
|
||||
var state = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var idClaim = state.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (idClaim != null && int.TryParse(idClaim, out var myId) && myId == deletedUserId)
|
||||
{
|
||||
await InvokeAsync(() => Nav.NavigateTo("/auth/logout", forceLoad: true));
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
UserNotificationService.OnUserDeleted -= HandleUserDeleted;
|
||||
}
|
||||
|
||||
private readonly MudTheme _theme = new()
|
||||
{
|
||||
PaletteLight = new PaletteLight
|
||||
{
|
||||
Primary = "#3F51B5",
|
||||
PrimaryDarken = "#1A237E",
|
||||
PrimaryLighten = "#7986CB",
|
||||
Secondary = "#009688",
|
||||
SecondaryDarken = "#00695C",
|
||||
AppbarBackground = "#3F51B5",
|
||||
Background = "#F4F6F9",
|
||||
DrawerBackground = "#FFFFFF",
|
||||
Surface = "#FFFFFF",
|
||||
TextPrimary = "#212121",
|
||||
TextSecondary = "#757575",
|
||||
}
|
||||
};
|
||||
|
||||
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
.page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
background-color: #f7f7f7;
|
||||
border-bottom: 1px solid #d6d5d5;
|
||||
justify-content: flex-end;
|
||||
height: 3.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
white-space: nowrap;
|
||||
margin-left: 1.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.top-row ::deep a:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (max-width: 640.98px) {
|
||||
.top-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.page {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
height: 100vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.top-row {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.top-row.auth ::deep a:first-child {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.top-row, article {
|
||||
padding-left: 2rem !important;
|
||||
padding-right: 1.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
#blazor-error-ui {
|
||||
color-scheme: light only;
|
||||
background: lightyellow;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<div style="display:flex; flex-direction:column; height:100%;">
|
||||
<MudNavMenu Style="flex:1">
|
||||
<MudDivider Class="mb-2" />
|
||||
<MudNavLink Href="" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.CalendarMonth">Wochenübersicht</MudNavLink>
|
||||
<MudNavLink Href="month" Icon="@Icons.Material.Filled.CalendarViewMonth">Monatsübersicht</MudNavLink>
|
||||
<MudNavLink Href="feiertage" Icon="@Icons.Material.Filled.Celebration">Feiertage</MudNavLink>
|
||||
<MudNavLink Href="urlaub-maximizer" Icon="@Icons.Material.Filled.AutoAwesome">Urlaubs-Maximizer</MudNavLink>
|
||||
<MudNavLink Href="settings" Icon="@Icons.Material.Filled.Settings">Einstellungen</MudNavLink>
|
||||
<MudDivider Class="mt-2 mb-2" />
|
||||
<AuthorizeView Policy="AdminOnly">
|
||||
<MudNavLink Href="admin/users" Icon="@Icons.Material.Filled.AdminPanelSettings" IconColor="Color.Error">
|
||||
Benutzerverwaltung
|
||||
</MudNavLink>
|
||||
</AuthorizeView>
|
||||
</MudNavMenu>
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<MudDivider />
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Class="px-4 py-2" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AccountCircle" Color="Color.Primary" Size="Size.Small" />
|
||||
<MudText Typo="Typo.body2" Style="font-weight:600">@context.User.Identity?.Name</MudText>
|
||||
</MudStack>
|
||||
<MudNavMenu>
|
||||
<MudNavLink Href="/auth/logout" Icon="@Icons.Material.Filled.Logout">Abmelden</MudNavLink>
|
||||
</MudNavMenu>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
<MudTooltip Text="Changelog anzeigen" Placement="Placement.Top">
|
||||
<MudNavMenu>
|
||||
<MudNavLink Href="/changelog" Icon="@Icons.Material.Filled.NewReleases"
|
||||
Style="color: var(--mud-palette-text-disabled); font-size:0.75rem;">
|
||||
Version 1.2
|
||||
</MudNavLink>
|
||||
</MudNavMenu>
|
||||
</MudTooltip>
|
||||
</div>
|
||||
@@ -0,0 +1,105 @@
|
||||
.navbar-toggler {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
width: 3.5rem;
|
||||
height: 2.5rem;
|
||||
color: white;
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.navbar-toggler:checked {
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
min-height: 3.5rem;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.bi {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-right: 0.75rem;
|
||||
top: -1px;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.bi-house-door-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-plus-square-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-list-nested-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
font-size: 0.9rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-item:first-of-type {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.nav-item:last-of-type {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link {
|
||||
color: #d7d7d7;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 3rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-item ::deep a.active {
|
||||
background-color: rgba(255,255,255,0.37);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link:hover {
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-toggler:checked ~ .nav-scrollable {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.navbar-toggler {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
/* Never collapse the sidebar for wide screens */
|
||||
display: block;
|
||||
|
||||
/* Allow sidebar to scroll for tall menus */
|
||||
height: calc(100vh - 3.5rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
@inject NavigationManager Nav
|
||||
|
||||
@code {
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
var returnUrl = Uri.EscapeDataString(Nav.Uri);
|
||||
Nav.NavigateTo($"/login?returnUrl={returnUrl}", forceLoad: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
@rendermode InteractiveWebAssembly
|
||||
|
||||
<Router AppAssembly="typeof(timetracker.Client._Imports).Assembly">
|
||||
<Found Context="routeData">
|
||||
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(timetracker.Client.Components.Layout.MainLayout)">
|
||||
<NotAuthorized>
|
||||
<timetracker.Client.Components.RedirectToLogin />
|
||||
</NotAuthorized>
|
||||
</AuthorizeRouteView>
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
</Router>
|
||||
@@ -0,0 +1,17 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using System.Security.Claims
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using timetracker.Client
|
||||
@using timetracker.Client.Components
|
||||
@using timetracker.Client.Components.Layout
|
||||
@using timetracker.Client.Services
|
||||
@using timetracker.Shared
|
||||
@using MudBlazor
|
||||
@@ -0,0 +1,27 @@
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||
using MudBlazor.Services;
|
||||
using timetracker.Client.Services;
|
||||
using timetracker.Shared;
|
||||
|
||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
|
||||
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
|
||||
|
||||
builder.Services.AddMudServices();
|
||||
|
||||
builder.Services.AddAuthorizationCore(options =>
|
||||
{
|
||||
options.AddPolicy("AdminOnly", policy =>
|
||||
policy.RequireClaim(System.Security.Claims.ClaimTypes.Name, "marc"));
|
||||
});
|
||||
|
||||
builder.Services.AddScoped<HostAuthenticationStateProvider>();
|
||||
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<HostAuthenticationStateProvider>());
|
||||
|
||||
builder.Services.AddScoped<IAuthService, ClientAuthService>();
|
||||
builder.Services.AddScoped<ITimetrackerService, ClientTimetrackerService>();
|
||||
builder.Services.AddScoped<IHolidayService, ClientHolidayService>();
|
||||
builder.Services.AddScoped<IUserNotificationService, UserNotificationService>();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
|
||||
"applicationUrl": "http://localhost:5016",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
|
||||
"applicationUrl": "https://localhost:7270;http://localhost:5016",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using timetracker.Shared;
|
||||
|
||||
namespace timetracker.Client.Services;
|
||||
|
||||
public class ClientAuthService : IAuthService
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly AuthenticationStateProvider _authStateProvider;
|
||||
|
||||
public ClientAuthService(HttpClient http, AuthenticationStateProvider authStateProvider)
|
||||
{
|
||||
_http = http;
|
||||
_authStateProvider = authStateProvider;
|
||||
}
|
||||
|
||||
private HostAuthenticationStateProvider HostProvider => (HostAuthenticationStateProvider)_authStateProvider;
|
||||
|
||||
public async Task<User?> LoginAsync(string username, string password)
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync("api/auth/login", new { Username = username, Password = password });
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var userInfo = await response.Content.ReadFromJsonAsync<UserInfo>();
|
||||
if (userInfo != null)
|
||||
{
|
||||
HostProvider.NotifyUserChanged(userInfo);
|
||||
return new User { Id = userInfo.Id, Username = userInfo.Username };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<(User? User, string? Error)> RegisterAsync(string username, string password)
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync("api/auth/register", new { Username = username, Password = password });
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var userInfo = await response.Content.ReadFromJsonAsync<UserInfo>();
|
||||
if (userInfo != null)
|
||||
{
|
||||
HostProvider.NotifyUserChanged(userInfo);
|
||||
return (new User { Id = userInfo.Id, Username = userInfo.Username }, null);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync();
|
||||
return (null, error);
|
||||
}
|
||||
return (null, "Registrierung fehlgeschlagen.");
|
||||
}
|
||||
|
||||
public async Task<List<User>> GetAllUsersAsync()
|
||||
{
|
||||
return await _http.GetFromJsonAsync<List<User>>("api/users") ?? [];
|
||||
}
|
||||
|
||||
public async Task DeleteUserAsync(int userId)
|
||||
{
|
||||
await _http.DeleteAsync($"api/users/{userId}");
|
||||
}
|
||||
|
||||
public async Task<string?> RenameUserAsync(int userId, string newUsername)
|
||||
{
|
||||
var response = await _http.PutAsJsonAsync($"api/users/{userId}/rename", new { Username = newUsername });
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Net.Http.Json;
|
||||
using timetracker.Shared;
|
||||
|
||||
namespace timetracker.Client.Services;
|
||||
|
||||
public class ClientHolidayService : IHolidayService
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public ClientHolidayService(HttpClient http)
|
||||
{
|
||||
_http = http;
|
||||
}
|
||||
|
||||
public async Task<List<PublicHoliday>> GetHolidaysAsync(int year, string? stateCode = null)
|
||||
{
|
||||
var url = $"api/holidays?year={year}";
|
||||
if (!string.IsNullOrEmpty(stateCode))
|
||||
{
|
||||
url += $"&stateCode={stateCode}";
|
||||
}
|
||||
return await _http.GetFromJsonAsync<List<PublicHoliday>>(url) ?? [];
|
||||
}
|
||||
|
||||
public async Task<(bool Success, string Message)> FetchAndStoreAsync(int year)
|
||||
{
|
||||
var response = await _http.PostAsync($"api/holidays/fetch/{year}", null);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var result = await response.Content.ReadFromJsonAsync<FetchResponse>();
|
||||
if (result != null)
|
||||
{
|
||||
return (result.Success, result.Message);
|
||||
}
|
||||
}
|
||||
return (false, "Fehler beim Abrufen der Feiertage.");
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id)
|
||||
{
|
||||
await _http.DeleteAsync($"api/holidays/{id}");
|
||||
}
|
||||
|
||||
private class FetchResponse
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Net.Http.Json;
|
||||
using timetracker.Shared;
|
||||
|
||||
namespace timetracker.Client.Services;
|
||||
|
||||
public class ClientTimetrackerService : ITimetrackerService
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public ClientTimetrackerService(HttpClient http)
|
||||
{
|
||||
_http = http;
|
||||
}
|
||||
|
||||
public async Task<List<WorkDay>> GetWeekAsync(int userId, DateOnly monday)
|
||||
{
|
||||
return await _http.GetFromJsonAsync<List<WorkDay>>($"api/tracker/week?userId={userId}&monday={monday:yyyy-MM-dd}") ?? [];
|
||||
}
|
||||
|
||||
public async Task UpsertWorkDayAsync(WorkDay workDay)
|
||||
{
|
||||
await _http.PostAsJsonAsync("api/tracker/workday", workDay);
|
||||
}
|
||||
|
||||
public async Task<AppSettings> GetSettingsAsync(int userId)
|
||||
{
|
||||
return await _http.GetFromJsonAsync<AppSettings>($"api/tracker/settings/{userId}") ?? new AppSettings { UserId = userId };
|
||||
}
|
||||
|
||||
public async Task SaveSettingsAsync(AppSettings settings)
|
||||
{
|
||||
await _http.PostAsJsonAsync("api/tracker/settings", settings);
|
||||
}
|
||||
|
||||
public async Task<List<VacationDay>> GetVacationDaysAsync(int userId, int year)
|
||||
{
|
||||
return await _http.GetFromJsonAsync<List<VacationDay>>($"api/tracker/vacation/{userId}/{year}") ?? [];
|
||||
}
|
||||
|
||||
public async Task AddVacationDayAsync(VacationDay vacationDay)
|
||||
{
|
||||
await _http.PostAsJsonAsync("api/tracker/vacation", vacationDay);
|
||||
}
|
||||
|
||||
public async Task RemoveVacationDayAsync(int userId, int id)
|
||||
{
|
||||
await _http.DeleteAsync($"api/tracker/vacation/{userId}/{id}");
|
||||
}
|
||||
|
||||
public async Task<TimeSpan> GetTotalOvertimeAsync(int userId, AppSettings settings)
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync($"api/tracker/overtime/{userId}", settings);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var hours = await response.Content.ReadFromJsonAsync<double>();
|
||||
return TimeSpan.FromHours(hours);
|
||||
}
|
||||
return TimeSpan.Zero;
|
||||
}
|
||||
|
||||
public async Task<List<WorkDay>> GetMonthAsync(int userId, int year, int month)
|
||||
{
|
||||
return await _http.GetFromJsonAsync<List<WorkDay>>($"api/tracker/month?userId={userId}&year={year}&month={month}") ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Security.Claims;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using timetracker.Shared;
|
||||
|
||||
namespace timetracker.Client.Services;
|
||||
|
||||
public class HostAuthenticationStateProvider : AuthenticationStateProvider
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private static readonly ClaimsPrincipal Anonymous = new(new ClaimsIdentity());
|
||||
private ClaimsPrincipal? _currentUser;
|
||||
|
||||
public HostAuthenticationStateProvider(HttpClient http)
|
||||
{
|
||||
_http = http;
|
||||
}
|
||||
|
||||
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||
{
|
||||
if (_currentUser != null)
|
||||
{
|
||||
return new AuthenticationState(_currentUser);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _http.GetAsync("api/auth/me");
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var userInfo = await response.Content.ReadFromJsonAsync<UserInfo>();
|
||||
if (userInfo != null)
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, userInfo.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, userInfo.Username)
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims, "Cookie");
|
||||
_currentUser = new ClaimsPrincipal(identity);
|
||||
return new AuthenticationState(_currentUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore error and fall back to anonymous (e.g. server offline or network issues)
|
||||
}
|
||||
|
||||
_currentUser = Anonymous;
|
||||
return new AuthenticationState(_currentUser);
|
||||
}
|
||||
|
||||
public void NotifyUserChanged(UserInfo? userInfo)
|
||||
{
|
||||
if (userInfo != null)
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, userInfo.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, userInfo.Username)
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims, "Cookie");
|
||||
_currentUser = new ClaimsPrincipal(identity);
|
||||
}
|
||||
else
|
||||
{
|
||||
_currentUser = Anonymous;
|
||||
}
|
||||
|
||||
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(_currentUser)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using timetracker.Shared;
|
||||
|
||||
namespace timetracker.Client.Services;
|
||||
|
||||
public class UserNotificationService : IUserNotificationService, IAsyncDisposable
|
||||
{
|
||||
private readonly HubConnection _hubConnection;
|
||||
|
||||
public event Func<Task>? OnUsersChanged;
|
||||
public event Func<int, Task>? OnUserDeleted;
|
||||
|
||||
public UserNotificationService(NavigationManager navigationManager)
|
||||
{
|
||||
_hubConnection = new HubConnectionBuilder()
|
||||
.WithUrl(navigationManager.ToAbsoluteUri("/hubs/notifications"))
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_hubConnection.On("UsersChanged", async () =>
|
||||
{
|
||||
if (OnUsersChanged != null)
|
||||
await OnUsersChanged.Invoke();
|
||||
});
|
||||
|
||||
_hubConnection.On<int>("UserDeleted", async (userId) =>
|
||||
{
|
||||
if (OnUserDeleted != null)
|
||||
await OnUserDeleted.Invoke(userId);
|
||||
});
|
||||
|
||||
// Start connection asynchronously
|
||||
_ = StartHubConnectionAsync();
|
||||
}
|
||||
|
||||
private async Task StartHubConnectionAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _hubConnection.StartAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"SignalR Connection Error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_hubConnection != null)
|
||||
{
|
||||
await _hubConnection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.AspNetCore.Components.WebAssembly.Http
|
||||
@using Microsoft.JSInterop
|
||||
@using timetracker.Client
|
||||
@using timetracker.Client.Components
|
||||
@using timetracker.Client.Components.Layout
|
||||
@using timetracker.Shared
|
||||
@using MudBlazor
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.5" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.8" />
|
||||
<PackageReference Include="MudBlazor" Version="9.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\timetracker.Shared\timetracker.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Reference in New Issue
Block a user