1 Commits

Author SHA1 Message Date
Wieland, Marc a6300251b1 buxfixes 2026-06-26 14:29:08 +02:00
172 changed files with 1379 additions and 12828 deletions
Vendored
BIN
View File
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 MiB

+26
View File
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<ResourcePreloader />
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
<link rel="stylesheet" href="_content/MudBlazor/MudBlazor.min.css" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["timetracker.styles.css"]" />
<ImportMap />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="alternate icon" type="image/png" href="favicon.png" />
<HeadOutlet />
</head>
<body>
<Routes />
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
</body>
</html>
+76
View File
@@ -0,0 +1,76 @@
@inherits LayoutComponentBase
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Nav
@inject timetracker.Data.UserNotificationService 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;
}
+98
View File
@@ -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;
}
+36
View File
@@ -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.1
</MudNavLink>
</MudNavMenu>
</MudTooltip>
</div>
+105
View File
@@ -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;
}
}
+31
View File
@@ -0,0 +1,31 @@
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
<dialog id="components-reconnect-modal" data-nosnippet>
<div class="components-reconnect-container">
<div class="components-rejoining-animation" aria-hidden="true">
<div></div>
<div></div>
</div>
<p class="components-reconnect-first-attempt-visible">
Rejoining the server...
</p>
<p class="components-reconnect-repeated-attempt-visible">
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
</p>
<p class="components-reconnect-failed-visible">
Failed to rejoin.<br />Please retry or reload the page.
</p>
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
Retry
</button>
<p class="components-pause-visible">
The session has been paused by the server.
</p>
<p class="components-resume-failed-visible">
Failed to resume the session.<br />Please retry or reload the page.
</p>
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
Resume
</button>
</div>
</dialog>
+157
View File
@@ -0,0 +1,157 @@
.components-reconnect-first-attempt-visible,
.components-reconnect-repeated-attempt-visible,
.components-reconnect-failed-visible,
.components-pause-visible,
.components-resume-failed-visible,
.components-rejoining-animation {
display: none;
}
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
#components-reconnect-modal.components-reconnect-retrying,
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-failed,
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
display: block;
}
#components-reconnect-modal {
background-color: white;
width: 20rem;
margin: 20vh auto;
padding: 2rem;
border: 0;
border-radius: 0.5rem;
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
opacity: 0;
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
&[open]
{
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
animation-fill-mode: both;
}
}
#components-reconnect-modal::backdrop {
background-color: rgba(0, 0, 0, 0.4);
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
opacity: 1;
}
@keyframes components-reconnect-modal-slideUp {
0% {
transform: translateY(30px) scale(0.95);
}
100% {
transform: translateY(0);
}
}
@keyframes components-reconnect-modal-fadeInOpacity {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes components-reconnect-modal-fadeOutOpacity {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.components-reconnect-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
#components-reconnect-modal p {
margin: 0;
text-align: center;
}
#components-reconnect-modal button {
border: 0;
background-color: #6b9ed2;
color: white;
padding: 4px 24px;
border-radius: 4px;
}
#components-reconnect-modal button:hover {
background-color: #3b6ea2;
}
#components-reconnect-modal button:active {
background-color: #6b9ed2;
}
.components-rejoining-animation {
position: relative;
width: 80px;
height: 80px;
}
.components-rejoining-animation div {
position: absolute;
border: 3px solid #0087ff;
opacity: 1;
border-radius: 50%;
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
.components-rejoining-animation div:nth-child(2) {
animation-delay: -0.5s;
}
@keyframes components-rejoining-animation {
0% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
4.9% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
5% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 1;
}
100% {
top: 0px;
left: 0px;
width: 80px;
height: 80px;
opacity: 0;
}
}
+63
View File
@@ -0,0 +1,63 @@
// Set up event handlers
const reconnectModal = document.getElementById("components-reconnect-modal");
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
const retryButton = document.getElementById("components-reconnect-button");
retryButton.addEventListener("click", retry);
const resumeButton = document.getElementById("components-resume-button");
resumeButton.addEventListener("click", resume);
function handleReconnectStateChanged(event) {
if (event.detail.state === "show") {
reconnectModal.showModal();
} else if (event.detail.state === "hide") {
reconnectModal.close();
} else if (event.detail.state === "failed") {
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
} else if (event.detail.state === "rejected") {
location.reload();
}
}
async function retry() {
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
try {
// Reconnect will asynchronously return:
// - true to mean success
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
// - exception to mean we didn't reach the server (this can be sync or async)
const successful = await Blazor.reconnect();
if (!successful) {
// We have been able to reach the server, but the circuit is no longer available.
// We'll reload the page so the user can continue using the app as quickly as possible.
const resumeSuccessful = await Blazor.resumeCircuit();
if (!resumeSuccessful) {
location.reload();
} else {
reconnectModal.close();
}
}
} catch (err) {
// We got an exception, server is currently unavailable
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
}
}
async function resume() {
try {
const successful = await Blazor.resumeCircuit();
if (!successful) {
location.reload();
}
} catch {
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
}
}
async function retryWhenDocumentBecomesVisible() {
if (document.visibilityState === "visible") {
await retry();
}
}
@@ -1,15 +1,10 @@
@page "/admin/users"
@rendermode InteractiveWebAssembly
@rendermode InteractiveServer
@attribute [Authorize(Policy = "AdminOnly")]
@using System.Net.Http.Json
@using System.Security.Claims
@inject IAuthService AuthService
@inject AuthService AuthService
@inject ISnackbar Snackbar
@inject AuthenticationStateProvider AuthStateProvider
@inject IUserNotificationService UserNotificationService
@inject HttpClient Http
@inject IDialogService DialogService
@inject NavigationManager NavigationManager
@inject timetracker.Data.UserNotificationService UserNotificationService
@implements IDisposable
<PageTitle>Benutzerverwaltung Timetracker</PageTitle>
@@ -27,7 +22,7 @@ else
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: #991B1B; color:white;">
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">
@@ -47,7 +42,6 @@ else
<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>Firma / Mandant</MudTh>
<MudTh Style="text-align:right">Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
@@ -80,34 +74,6 @@ else
</MudStack>
}
</MudTd>
<MudTd>
@if (_editUserId == context.Id)
{
<MudSelect @bind-Value="_editTenantId" Margin="Margin.Dense" T="int?" Label="Firma zuweisen" Variant="Variant.Outlined" Style="max-width:250px">
<MudSelectItem T="int?" Value="@((int?)null)">Hauptdomain (System/Global)</MudSelectItem>
@foreach (var tenant in _tenants)
{
<MudSelectItem T="int?" Value="@((int?)tenant.Id)">@tenant.Name (@tenant.Subdomain)</MudSelectItem>
}
</MudSelect>
}
else
{
@if (context.TenantId.HasValue)
{
var t = _tenants.FirstOrDefault(x => x.Id == context.TenantId.Value);
<MudChip T="string" Color="Color.Primary" Variant="Variant.Outlined" Size="Size.Small">
@(t?.Name ?? $"Firma #{context.TenantId}")
</MudChip>
}
else
{
<MudChip T="string" Color="Color.Default" Variant="Variant.Text" Size="Size.Small">
@(context.Username == "marc" ? "SuperAdmin" : "Einzelnutzer")
</MudChip>
}
}
</MudTd>
<MudTd Style="text-align:right">
@if (_editUserId == context.Id)
{
@@ -126,18 +92,6 @@ else
Color="Color.Primary"
Size="Size.Small"
OnClick="@(() => StartEdit(context))" />
<MudTooltip Text="Passwort zurücksetzen">
<MudIconButton Icon="@Icons.Material.Filled.LockReset"
Color="Color.Warning"
Size="Size.Small"
OnClick="@(() => OpenResetPasswordDialog(context))" />
</MudTooltip>
<MudTooltip Text="Stundenzettel (PDF) herunterladen">
<MudIconButton Icon="@Icons.Material.Filled.PictureAsPdf"
Color="Color.Info"
Size="Size.Small"
OnClick="@(() => DownloadUserPdf(context))" />
</MudTooltip>
@if (context.Username != "marc")
{
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline"
@@ -159,63 +113,25 @@ else
}
@code {
[PersistentState(AllowUpdates = true)]
public List<User>? PersistentUsers { get; set; }
[PersistentState(AllowUpdates = true)]
public List<TenantStats>? PersistentTenants { get; set; }
private List<User> _users = [];
private List<TenantStats> _tenants = [];
private bool _loading = true;
private int? _editUserId;
private string _editUsername = "";
private int? _editTenantId;
protected override async Task OnInitializedAsync()
{
if (PersistentUsers != null)
{
_users = PersistentUsers;
_tenants = PersistentTenants ?? [];
_loading = false;
}
var claim = (await AuthStateProvider.GetAuthenticationStateAsync())
.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return;
UserNotificationService.OnUsersChanged += RefreshUsers;
if (PersistentUsers == null)
{
try
{
_users = await AuthService.GetAllUsersAsync();
_tenants = await Http.GetFromJsonAsync<List<TenantStats>>("api/superadmin/tenants") ?? [];
PersistentUsers = _users;
PersistentTenants = _tenants;
_loading = false;
}
catch (Exception ex)
{
Snackbar.Add($"Fehler beim Laden der Daten: {ex.Message}", Severity.Error);
}
}
_users = await AuthService.GetAllUsersAsync();
_loading = false;
}
private async Task RefreshUsers()
{
_users = await AuthService.GetAllUsersAsync();
try
{
_tenants = await Http.GetFromJsonAsync<List<TenantStats>>("api/superadmin/tenants") ?? [];
}
catch
{
// Ignore background refresh errors
}
await InvokeAsync(StateHasChanged);
}
@@ -228,49 +144,27 @@ else
{
_editUserId = user.Id;
_editUsername = user.Username;
_editTenantId = user.TenantId;
}
private void CancelEdit()
{
_editUserId = null;
_editUsername = "";
_editTenantId = null;
}
private async Task SaveRename(User user)
{
var trimmed = _editUsername.Trim();
// 1. Rename if username changed
if (trimmed != user.Username)
var error = await AuthService.RenameUserAsync(user.Id, trimmed);
if (error != null)
{
var error = await AuthService.RenameUserAsync(user.Id, trimmed);
if (error != null)
{
Snackbar.Add(error, Severity.Error);
return;
}
user.Username = trimmed;
Snackbar.Add(error, Severity.Error);
return;
}
// 2. Move to tenant if tenant ID changed
if (_editTenantId != user.TenantId)
{
var error = await AuthService.AssignTenantAsync(user.Id, _editTenantId);
if (error != null)
{
Snackbar.Add(error, Severity.Error);
return;
}
user.TenantId = _editTenantId;
}
user.Username = trimmed;
_editUserId = null;
_editUsername = "";
_editTenantId = null;
Snackbar.Add("Benutzerdaten erfolgreich aktualisiert.", Severity.Success);
Snackbar.Add($"Benutzer umbenannt zu \"{trimmed}\".", Severity.Success);
}
private async Task DeleteUser(User user)
@@ -279,44 +173,4 @@ else
_users.Remove(user);
Snackbar.Add($"Benutzer \"{user.Username}\" gelöscht.", Severity.Info);
}
private async Task OpenResetPasswordDialog(User user)
{
var parameters = new DialogParameters<ResetPasswordDialog>
{
{ x => x.Username, user.Username }
};
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = await DialogService.ShowAsync<ResetPasswordDialog>("Passwort zurücksetzen", parameters, options);
var result = await dialog.Result;
if (result != null && !result.Canceled && result.Data is string newPassword)
{
var error = await AuthService.ResetPasswordAsync(user.Id, newPassword);
if (error != null)
{
Snackbar.Add(error, Severity.Error);
}
else
{
Snackbar.Add($"Passwort für {user.Username} wurde erfolgreich zurückgesetzt.", Severity.Success);
}
}
}
private async Task DownloadUserPdf(User user)
{
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true };
var dialog = await DialogService.ShowAsync<MonthSelectorDialog>($"Stundenzettel für {user.Username}", options);
var result = await dialog.Result;
if (result != null && !result.Canceled && result.Data is MonthSelectorDialog.MonthSelectorResult data)
{
int year = data.Year;
int month = data.Month;
NavigationManager.NavigateTo($"api/tracker/export/month/pdf?userId={user.Id}&year={year}&month={month}", forceLoad: true);
}
}
}
@@ -1,24 +1,24 @@
@page "/changelog"
@rendermode InteractiveWebAssembly
@attribute [AllowAnonymous]
@inject IJSRuntime JSRuntime
@rendermode InteractiveServer
@attribute [Authorize]
<PageTitle>Changelog Timetracker</PageTitle>
<MudStack Spacing="4">
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
<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: var(--mud-palette-primary-text); font-size:2rem" />
<MudIcon Icon="@Icons.Material.Filled.NewReleases" Style="color:white; font-size:2rem" />
<MudStack Spacing="0">
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700">Changelog</MudText>
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">Versionshistorie &amp; Änderungen</MudText>
<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 &amp; Änderungen</MudText>
</MudStack>
</MudStack>
</MudPaper>
@foreach (var release in Releases)
@foreach (var release in _releases)
{
<MudCard Elevation="2" Class="rounded-xl">
<MudCardContent>
@@ -55,44 +55,20 @@
</MudStack>
@code {
public record ChangeEntry(string Tag, string Text);
public record Release(string Version, string Date, bool IsLatest, List<ChangeEntry> Entries, bool Highlighted = false);
private record ChangeEntry(string Tag, string Text);
private record Release(string Version, string Date, bool IsLatest, List<ChangeEntry> Entries);
public static readonly List<Release> Releases =
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.7", "27.06.2026", true,
[
new("Neu", "Excel-Export"),
new("Upgrade", "Besseres Page-Loading")
], Highlighted: true),
new("1.6", "25.06.2026", true,
[
new("Neu", "Userprofil angelegt"),
new("Upgrade", "Früheste Gehenszeit wird angezeigt"),
new("Upgrade", "Pausen werden dynamisch berechnet"),
new("Neu", "Multi-Tenant Integration"),
new("Upgrade", "Verbesserte Single-User Ansicht")
]),
new("1.4", "08.06.2026", true,
[
new("Neu", "Timebot implementiert"),
new("Neu", "Onboarding Tour"),
new("Upgrade", "Security hardening"),
new("Upgrade", "Changed coloring"),
]),
new("1.3", "08.06.2026", false,
[
new("Fix", "Browser-Tab hat statischen Text angezeigt und sich nicht dynamisch an die jeweilige Seite angepasst"),
]),
new("1.2", "08.06.2026", false,
[
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("1.1", "08.06.2026", false,
new("1.1", "08.06.2026", true,
[
new("Neu", "Versionsnummer in der Navbar mit Link zum Changelog"),
new("Neu", "Changelog-Seite"),
@@ -113,30 +89,4 @@
new("Neu", "Registrierung und Login"),
]),
];
public static string LatestVersion => Releases.FirstOrDefault()?.Version ?? "";
public static bool IsLatestHighlighted => Releases.FirstOrDefault()?.Highlighted ?? false;
private static Color GetTagColor(string tag) => tag switch
{
"Neu" => Color.Success,
"Fix" => Color.Error,
"Upgrade" => Color.Info,
_ => Color.Default
};
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "lastSeenVersion", LatestVersion);
}
catch
{
// Ignored in SSR
}
}
}
}
@@ -1,8 +1,8 @@
@page "/feiertage"
@rendermode InteractiveWebAssembly
@rendermode InteractiveServer
@attribute [Authorize]
@inject IHolidayService HolidayService
@inject ITimetrackerService TrackerService
@inject HolidayService HolidayService
@inject TimetrackerService TrackerService
@inject AuthenticationStateProvider AuthStateProvider
<PageTitle>Feiertage Timetracker</PageTitle>
@@ -20,7 +20,7 @@ else
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: #0F766E; color: white;">
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" />
@@ -140,7 +140,7 @@ else
@* ── Zusammenfassung ── *@
<MudPaper Elevation="2" Class="pa-4 rounded-xl"
Style="background: linear-gradient(90deg, rgba(15,118,110,0.08) 0%, transparent 100%); border-left: 4px solid #0F766E;">
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" />
@@ -183,18 +183,6 @@ else
@code {
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
[PersistentState(AllowUpdates = true)]
public AppSettings? PersistentSettings { get; set; }
[PersistentState(AllowUpdates = true)]
public List<PublicHoliday>? PersistentHolidays { get; set; }
[PersistentState(AllowUpdates = true)]
public string? PersistentSubLabel { get; set; }
[PersistentState(AllowUpdates = true)]
public int? PersistentYear { get; set; }
private bool _loading = true;
private int _year = DateTime.Today.Year;
private List<PublicHoliday> _holidays = [];
@@ -205,38 +193,15 @@ else
protected override async Task OnInitializedAsync()
{
if (PersistentSettings != null && PersistentHolidays != null)
{
_settings = PersistentSettings;
_holidays = PersistentHolidays;
_subLabel = PersistentSubLabel ?? "";
_year = PersistentYear ?? DateTime.Today.Year;
_loading = false;
}
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var claim = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
if (claim != null)
{
_userId = int.Parse(claim.Value);
if (PersistentSettings == null || PersistentHolidays == null)
{
_settings = await TrackerService.GetSettingsAsync(_userId);
await LoadHolidays();
PersistentSettings = _settings;
PersistentHolidays = _holidays;
PersistentSubLabel = _subLabel;
PersistentYear = _year;
_loading = false;
}
}
else
{
await LoadHolidays();
_loading = false;
_settings = await TrackerService.GetSettingsAsync(_userId);
}
await LoadHolidays();
_loading = false;
}
private async Task LoadHolidays()
@@ -1,12 +1,10 @@
@page "/week"
@rendermode InteractiveWebAssembly
@page "/"
@rendermode InteractiveServer
@attribute [Authorize]
@inject ITimetrackerService TrackerService
@inject IHolidayService HolidayService
@inject TimetrackerService TrackerService
@inject HolidayService HolidayService
@inject ISnackbar Snackbar
@inject AuthenticationStateProvider AuthStateProvider
@inject IJSRuntime JSRuntime
@inject NavigationManager NavigationManager
<PageTitle>KW @_kw Wochenübersicht Timetracker</PageTitle>
@@ -22,32 +20,29 @@ else
<MudStack Spacing="3">
@* ── Wochen-Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl onboarding-week-header mud-theme-primary">
<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: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="PrevWeek" />
Style="color:white" Size="Size.Large" OnClick="PrevWeek" />
<MudStack Spacing="0" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700; letter-spacing:0.5px">
<MudText Typo="Typo.h5" Style="color:white; font-weight:700; letter-spacing:0.5px">
@_weekLabel
</MudText>
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
@_weekSubLabel
</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
<MudTooltip Text="Woche als Excel exportieren">
<MudIconButton Icon="@Icons.Material.Filled.Download"
Style="color: var(--mud-palette-primary-text)" OnClick="ExportWeekExcel" />
</MudTooltip>
@if (!IsCurrentWeek)
{
<MudButton Variant="Variant.Text" Style="color: var(--mud-palette-primary-text)"
<MudButton Variant="Variant.Text" Style="color:white"
OnClick="GoToCurrentWeek" Size="Size.Small">
Heute
</MudButton>
}
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="NextWeek" />
Style="color:white" Size="Size.Large" OnClick="NextWeek" />
</MudStack>
</MudStack>
</MudPaper>
@@ -98,9 +93,9 @@ else
else
{
@* ── Arbeitstag: vollständige Karte ── *@
<MudCard @key="@day.Date" Elevation="@(isToday ? 6 : 2)" Class="rounded-xl onboarding-day-card"
<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(14,165,233,0.07) 0%, transparent 100%);" : "")">
<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">
@@ -143,30 +138,9 @@ else
}
else if (!hasData)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
@if (day.IsVacation)
{
<MudChip T="string" Color="Color.Info" Variant="Variant.Filled" Size="Size.Small" Style="font-weight:700">
URLAUB
</MudChip>
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Error"
OnClick="@(async () => await RemoveVacation(day))"
Style="text-transform:none; font-size: 0.75rem;">
Urlaub entfernen
</MudButton>
}
else
{
<MudChip T="string" Color="Color.Default" Variant="Variant.Outlined" Size="Size.Small">
Nicht erfasst
</MudChip>
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Info"
OnClick="@(async () => await MarkAsVacation(day))"
Style="text-transform:none; font-size: 0.75rem;">
Als Urlaub eintragen
</MudButton>
}
</MudStack>
<MudChip T="string" Color="Color.Default" Variant="Variant.Outlined" Size="Size.Small">
Nicht erfasst
</MudChip>
}
</MudStack>
</CardHeaderContent>
@@ -203,43 +177,19 @@ else
<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(GetEffectiveBreakTime(day))</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(GetEffectiveNetWork(day)!.Value)
@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(GetDailyTargetHours(day)))</MudText>
</MudStack>
<MudDivider Vertical="true" FlexItem="true" Style="height:36px" />
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Color="Color.Secondary">Soll-Ende</MudText>
<MudText Typo="Typo.body1">@FormatTimeSpan(CalculateEarliestDeparture(day)) Uhr</MudText>
</MudStack>
</MudStack>
</MudItem>
}
else if (day.Start.HasValue)
{
<MudItem xs="12" md="8">
<MudStack Row="true" Spacing="3" AlignItems="AlignItems.Center" Class="h-100 pl-1">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-info);">Früheste Gehenszeit</MudText>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.ExitToApp" Color="Color.Info" Size="Size.Small" />
<MudText Typo="Typo.body1" Color="Color.Info" Style="font-weight:700">
@FormatTimeSpan(CalculateEarliestDeparture(day)) Uhr
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="ml-2">
(Bei Soll: @FormatTs(TimeSpan.FromHours(_settings.DailyTargetHours))h + @((int)GetRequiredBreakForTarget(day).TotalMinutes) Min. Pause)
</MudText>
</MudStack>
<MudText Typo="Typo.body1">@FormatTs(TimeSpan.FromHours(_settings.DailyTargetHours))</MudText>
</MudStack>
</MudStack>
</MudItem>
@@ -249,7 +199,7 @@ else
@* ── Fortschrittsbalken ── *@
@if (progressPct >= 0)
{
<MudTooltip Text="@($"Netto {FormatTs(GetEffectiveNetWork(day) ?? TimeSpan.Zero)} von {FormatTs(TimeSpan.FromHours(GetDailyTargetHours(day)))} Soll")">
<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" />
@@ -319,35 +269,35 @@ else
}
@* ── Wochensumme ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl onboarding-week-summary"
Style="background: var(--mud-palette-primary-darken); color: var(--mud-palette-primary-text);">
<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: var(--mud-palette-primary-text); opacity: 0.8" />
<MudText Typo="Typo.h6" Style="color: var(--mud-palette-primary-text); font-weight:600">Wochensumme</MudText>
<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: var(--mud-palette-primary-text); opacity: 0.6">Brutto</MudText>
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text)">@FormatTs(WeekGross)</MudText>
<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: var(--mud-palette-primary-text); opacity: 0.6">Pausen</MudText>
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text)">@FormatTs(WeekBreaks)</MudText>
<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: var(--mud-palette-primary-text); opacity: 0.6">Netto</MudText>
<MudText Typo="Typo.h5" Color="Color.Secondary" Style="font-weight:700">@FormatTs(WeekNet)</MudText>
<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: var(--mud-palette-primary-text); opacity: 0.6">Gleitzeit</MudText>
<MudText Typo="Typo.h5" Color="@(WeekOvertime >= TimeSpan.Zero ? Color.Success : Color.Warning)" Style="font-weight:700">
<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>
@@ -356,7 +306,7 @@ else
</MudPaper>
@* ── Gleitzeitkonto ── *@
<MudPaper Elevation="3" Class="pa-5 rounded-xl onboarding-overtime-balance"
<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">
@@ -378,40 +328,11 @@ else
</MudPaper>
</MudStack>
@if (_showOnboarding)
{
<OnboardingTour OnFinished="HandleOnboardingFinished" />
}
}
@code {
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
[PersistentState(AllowUpdates = true)]
public AppSettings? PersistentSettings { get; set; }
[PersistentState(AllowUpdates = true)]
public List<DayVm>? PersistentDays { get; set; }
[PersistentState(AllowUpdates = true)]
public TimeSpan? PersistentTotalOvertime { get; set; }
[PersistentState(AllowUpdates = true)]
public Dictionary<DateOnly, string>? PersistentHolidays { get; set; }
[PersistentState(AllowUpdates = true)]
public int? PersistentHolidayYear { get; set; }
[PersistentState(AllowUpdates = true)]
public List<VacationDay>? PersistentVacations { get; set; }
[PersistentState(AllowUpdates = true)]
public string? PersistentWeekLabel { get; set; }
[PersistentState(AllowUpdates = true)]
public string? PersistentWeekSubLabel { get; set; }
private bool _loading = true;
private int _userId;
private DateOnly _monday;
@@ -426,102 +347,35 @@ else
private TimeSpan _totalOvertime;
private Dictionary<DateOnly, string> _holidays = [];
private int _holidayYear = -1;
private bool _showOnboarding;
private List<VacationDay> _vacations = [];
private bool IsCurrentWeek => _monday == GetMonday(DateOnly.FromDateTime(DateTime.Today));
protected override async Task OnInitializedAsync()
{
if (PersistentSettings != null && PersistentDays != null && PersistentTotalOvertime != null)
{
_settings = PersistentSettings;
_days = PersistentDays;
_totalOvertime = PersistentTotalOvertime.Value;
_holidays = PersistentHolidays ?? [];
_holidayYear = PersistentHolidayYear ?? -1;
_vacations = PersistentVacations ?? [];
_weekLabel = PersistentWeekLabel ?? "";
_weekSubLabel = PersistentWeekSubLabel ?? "";
_loading = false;
}
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));
if (PersistentSettings == null || PersistentDays == null || PersistentTotalOvertime == null)
{
_settings = await TrackerService.GetSettingsAsync(_userId);
var loadWeekTask = LoadWeek();
var overtimeTask = TrackerService.GetTotalOvertimeAsync(_userId, _settings);
await Task.WhenAll(loadWeekTask, overtimeTask);
_totalOvertime = await overtimeTask;
PersistentSettings = _settings;
PersistentDays = _days;
PersistentTotalOvertime = _totalOvertime;
PersistentHolidays = _holidays;
PersistentHolidayYear = _holidayYear;
PersistentVacations = _vacations;
PersistentWeekLabel = _weekLabel;
PersistentWeekSubLabel = _weekSubLabel;
_loading = false;
}
try
{
var showOnb = await JSRuntime.InvokeAsync<string>("localStorage.getItem", "showOnboarding");
_showOnboarding = showOnb == "true";
}
catch
{
// Ignored during prerendering/SSR
}
}
private async Task HandleOnboardingFinished()
{
_showOnboarding = false;
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "showOnboarding", "false");
_settings = await TrackerService.GetSettingsAsync(_userId);
await LoadWeek();
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
_loading = false;
}
private async Task LoadWeek()
{
Task<List<PublicHoliday>> holidaysTask;
if (_monday.Year != _holidayYear)
{
holidaysTask = HolidayService.GetHolidaysAsync(_monday.Year, _settings.GermanState);
}
else
{
holidaysTask = Task.FromResult(new List<PublicHoliday>());
}
var dbDaysTask = TrackerService.GetWeekAsync(_userId, _monday);
var vacationsTask = TrackerService.GetVacationDaysAsync(_userId, _monday.Year);
await Task.WhenAll(holidaysTask, dbDaysTask, vacationsTask);
if (_monday.Year != _holidayYear)
{
var list = await holidaysTask;
var list = await HolidayService.GetHolidaysAsync(_monday.Year, _settings.GermanState);
_holidays = list.ToDictionary(h => h.Date, h => h.Name);
_holidayYear = _monday.Year;
}
_vacations = await vacationsTask;
var dbDays = await dbDaysTask;
var dbDays = await TrackerService.GetWeekAsync(_userId, _monday);
_days = Enumerable.Range(0, 7).Select(i =>
{
var date = _monday.AddDays(i);
var dayVm = DayVm.From(dbDays.FirstOrDefault(d => d.Date == date), date, _userId);
dayVm.IsVacation = _vacations.Any(v => v.Date == date);
return dayVm;
return DayVm.From(dbDays.FirstOrDefault(d => d.Date == date), date, _userId);
}).ToList();
BuildWeekLabels();
}
@@ -545,10 +399,6 @@ else
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 void ExportWeekExcel()
{
NavigationManager.NavigateTo($"api/tracker/export/week?monday={_monday:yyyy-MM-dd}", forceLoad: true);
}
private static DateOnly GetMonday(DateOnly date)
{
@@ -583,61 +433,21 @@ else
}
// ── Berechnungen ─────────────────────────────────────────────
private TimeSpan GetEffectiveBreakTime(DayVm day)
{
var enteredBreak = day.Breaks
.Where(b => b.Duration.HasValue)
.Aggregate(TimeSpan.Zero, (s, b) => s + b.Duration!.Value);
if (!day.Start.HasValue || !day.End.HasValue)
return enteredBreak;
var gross = day.End.Value - day.Start.Value;
int minMinutes = gross.TotalHours >= 9.0 ? 45 : 30;
int finalMin = Math.Max(minMinutes, _settings.MinimumBreakMinutes);
var minBreak = TimeSpan.FromMinutes(finalMin);
return enteredBreak > minBreak ? enteredBreak : minBreak;
}
private TimeSpan? GetEffectiveNetWork(DayVm day)
{
if (!day.GrossWork.HasValue) return null;
return day.GrossWork.Value - GetEffectiveBreakTime(day);
}
private double GetDailyTargetHours(DayVm day)
{
var isHoliday = _holidays.ContainsKey(day.Date);
var isWorkDay = _settings.IsWorkDay(day.Date.DayOfWeek);
if (!isWorkDay || isHoliday || day.IsVacation)
{
return 0.0;
}
return _settings.DailyTargetHours;
}
private TimeSpan? GetOvertime(DayVm day)
{
var net = GetEffectiveNetWork(day);
return net.HasValue ? net.Value - TimeSpan.FromHours(GetDailyTargetHours(day)) : null;
}
private TimeSpan? GetOvertime(DayVm day) =>
day.NetWork.HasValue ? day.NetWork.Value - TimeSpan.FromHours(_settings.DailyTargetHours) : null;
private int GetProgressPercent(DayVm day)
{
var net = GetEffectiveNetWork(day);
var target = GetDailyTargetHours(day);
if (!net.HasValue || target <= 0) return -1;
return (int)(net.Value.TotalHours / target * 100);
if (!day.NetWork.HasValue || _settings.DailyTargetHours <= 0) return -1;
return (int)(day.NetWork.Value.TotalHours / _settings.DailyTargetHours * 100);
}
private string GetBorderColor(DayVm day, bool isWorkDay, List<string> errors)
private static string GetBorderColor(DayVm day, bool isWorkDay, List<string> errors)
{
if (!isWorkDay) return "#CFD8DC";
if (errors.Count > 0) return "#EF5350";
if (day.IsVacation) return "#0EA5E9"; // Cool blue for vacation
if (!day.Start.HasValue && !day.End.HasValue) return "#B0BEC5";
if (!GetEffectiveNetWork(day).HasValue) return "#B0BEC5";
if (!day.NetWork.HasValue) return "#B0BEC5";
return "#4CAF50";
}
@@ -664,17 +474,11 @@ else
yield return $"Pause {i + 1} und Pause {j + 1} überschneiden sich.";
}
}
var net = GetEffectiveNetWork(day);
if (net.HasValue && net.Value.TotalHours > 10.0)
{
yield return "Die tägliche Arbeitszeit überschreitet das gesetzliche Maximum von 10 Stunden!";
}
}
private TimeSpan WeekGross => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (d.GrossWork ?? TimeSpan.Zero));
private TimeSpan WeekBreaks => _days.Aggregate(TimeSpan.Zero, (s, d) => s + GetEffectiveBreakTime(d));
private TimeSpan WeekNet => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (GetEffectiveNetWork(d) ?? 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)
@@ -685,64 +489,8 @@ else
return $"{prefix}{(int)abs.TotalHours}:{abs.Minutes:D2}";
}
private TimeSpan GetRequiredBreakForTarget(DayVm day)
{
var targetNet = _settings.DailyTargetHours;
var minMinutes = targetNet >= 8.5 ? 45 : 30;
var requiredBreak = TimeSpan.FromMinutes(Math.Max(minMinutes, _settings.MinimumBreakMinutes));
var enteredBreak = day.Breaks
.Where(b => b.Duration.HasValue)
.Aggregate(TimeSpan.Zero, (s, b) => s + b.Duration!.Value);
return enteredBreak > requiredBreak ? enteredBreak : requiredBreak;
}
private TimeSpan CalculateEarliestDeparture(DayVm day)
{
if (!day.Start.HasValue) return TimeSpan.Zero;
var target = TimeSpan.FromHours(_settings.DailyTargetHours);
var finalBreak = GetRequiredBreakForTarget(day);
return day.Start.Value + target + finalBreak;
}
private async Task MarkAsVacation(DayVm day)
{
var vacation = new VacationDay
{
UserId = _userId,
Date = day.Date,
Note = "Über Wochenübersicht eingetragen"
};
await TrackerService.AddVacationDayAsync(vacation);
await LoadWeek();
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
Snackbar.Add($"{day.Date:dd.MM.yyyy} als Urlaub eingetragen.", Severity.Success);
}
private async Task RemoveVacation(DayVm day)
{
var item = _vacations.FirstOrDefault(v => v.Date == day.Date);
if (item != null)
{
await TrackerService.RemoveVacationDayAsync(_userId, item.Id);
await LoadWeek();
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
Snackbar.Add($"Urlaub für {day.Date:dd.MM.yyyy} entfernt.", Severity.Info);
}
}
private static string FormatTimeSpan(TimeSpan? ts)
{
if (!ts.HasValue) return "";
var hours = ((int)ts.Value.TotalHours) % 24;
return $"{hours:D2}:{ts.Value.Minutes:D2}";
}
// ── ViewModels ────────────────────────────────────────────────
public sealed class DayVm
private sealed class DayVm
{
public int Id { get; set; }
public int UserId { get; set; }
@@ -750,7 +498,6 @@ else
public TimeSpan? Start { get; set; }
public TimeSpan? End { get; set; }
public List<BreakVm> Breaks { get; set; } = [];
public bool IsVacation { get; set; }
public TimeSpan? GrossWork =>
Start.HasValue && End.HasValue && End > Start ? End.Value - Start.Value : null;
@@ -792,7 +539,7 @@ else
};
}
public sealed class BreakVm
private sealed class BreakVm
{
public int Id { get; set; }
public TimeSpan? Start { get; set; }
+140
View File
@@ -0,0 +1,140 @@
@page "/login"
@attribute [AllowAnonymous]
@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%">
@* ── Static Tab Navigation ── *@
<MudStack Row="true" Justify="Justify.Center" Class="mb-4">
<MudButton Href="/login"
Variant="@(_activeTab == 0 ? Variant.Filled : Variant.Text)"
Color="Color.Primary"
Style="min-width: 120px; border-radius: 20px;">
Anmelden
</MudButton>
<MudButton Href="/login?tab=register"
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>
}
<form action="/auth/login" method="post">
<MudStack Spacing="3">
<MudTextField T="string"
Label="Benutzername"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Person"
name="username"
Required="true"
AutoFocus="true" />
<MudTextField T="string"
Label="Passwort"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Lock"
InputType="InputType.Password"
name="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">
Anmelden
</MudButton>
</MudStack>
</form>
</MudStack>
}
else
{
@* ── Register Form ── *@
<MudStack Spacing="3">
@if (_error != null)
{
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
}
<form action="/auth/register" method="post">
<MudStack Spacing="3">
<MudTextField T="string"
Label="Benutzername"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Person"
name="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"
name="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">
Konto erstellen
</MudButton>
</MudStack>
</form>
</MudStack>
}
</MudPaper>
</MudStack>
</MudContainer>
@code {
private int _activeTab = 0;
private string? _error;
[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;
}
}
@@ -1,10 +1,9 @@
@page "/month"
@rendermode InteractiveWebAssembly
@rendermode InteractiveServer
@attribute [Authorize]
@inject ITimetrackerService TrackerService
@inject IHolidayService HolidayService
@inject TimetrackerService TrackerService
@inject HolidayService HolidayService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
<PageTitle>@_deCulture.DateTimeFormat.GetMonthName(_month) @_year Monatsübersicht Timetracker</PageTitle>
@@ -20,36 +19,29 @@ else
<MudStack Spacing="3">
@* ── Monats-Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
<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: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="PrevMonth" />
Style="color:white" Size="Size.Large" OnClick="PrevMonth" />
<MudStack Spacing="0" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700; letter-spacing:0.5px">
<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: var(--mud-palette-primary-text); opacity: 0.72">
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
@_subLabel
</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
<MudTooltip Text="Stundenzettel (PDF) herunterladen">
<MudIconButton Icon="@Icons.Material.Filled.PictureAsPdf"
Style="color: var(--mud-palette-primary-text)" OnClick="ExportMonthPdf" />
</MudTooltip>
<MudTooltip Text="Monat als Excel exportieren">
<MudIconButton Icon="@Icons.Material.Filled.Download"
Style="color: var(--mud-palette-primary-text)" OnClick="ExportMonthExcel" />
</MudTooltip>
@if (!IsCurrentMonth)
{
<MudButton Variant="Variant.Text" Style="color: var(--mud-palette-primary-text)"
<MudButton Variant="Variant.Text" Style="color:white"
OnClick="GoToCurrentMonth" Size="Size.Small">
Heute
</MudButton>
}
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="NextMonth" />
Style="color:white" Size="Size.Large" OnClick="NextMonth" />
</MudStack>
</MudStack>
</MudPaper>
@@ -59,7 +51,7 @@ else
<MudCardContent Class="pa-0">
<MudSimpleTable Dense="true" Striped="false" Hover="true" Style="overflow-x:auto">
<thead>
<tr style="background: rgba(14,165,233,0.08);">
<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>
@@ -103,7 +95,7 @@ else
@* ── Monatszusammenfassung ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, color-mix(in srgb, var(--mud-palette-primary) 8%, transparent) 0%, color-mix(in srgb, var(--mud-palette-primary) 2%, transparent) 100%); border-left: 6px solid var(--mud-palette-primary);">
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>
@@ -159,33 +151,6 @@ else
@code {
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
[PersistentState(AllowUpdates = true)]
public AppSettings? PersistentSettings { get; set; }
[PersistentState(AllowUpdates = true)]
public List<MonthDayVm>? PersistentDays { get; set; }
[PersistentState(AllowUpdates = true)]
public string? PersistentSubLabel { get; set; }
[PersistentState(AllowUpdates = true)]
public TimeSpan? PersistentMonthNet { get; set; }
[PersistentState(AllowUpdates = true)]
public TimeSpan? PersistentMonthOvertime { get; set; }
[PersistentState(AllowUpdates = true)]
public int? PersistentRecordedWorkDays { get; set; }
[PersistentState(AllowUpdates = true)]
public int? PersistentTotalWorkDays { get; set; }
[PersistentState(AllowUpdates = true)]
public int? PersistentVacationCount { get; set; }
[PersistentState(AllowUpdates = true)]
public int? PersistentHolidayCount { get; set; }
private bool _loading = true;
private int _userId;
private int _year = DateTime.Today.Year;
@@ -205,54 +170,20 @@ else
protected override async Task OnInitializedAsync()
{
if (PersistentSettings != null && PersistentDays != null)
{
_settings = PersistentSettings;
_days = PersistentDays;
_subLabel = PersistentSubLabel ?? "";
_monthNet = PersistentMonthNet ?? TimeSpan.Zero;
_monthOvertime = PersistentMonthOvertime ?? TimeSpan.Zero;
_recordedWorkDays = PersistentRecordedWorkDays ?? 0;
_totalWorkDays = PersistentTotalWorkDays ?? 0;
_vacationCount = PersistentVacationCount ?? 0;
_holidayCount = PersistentHolidayCount ?? 0;
_loading = false;
}
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return;
_userId = int.Parse(claim.Value);
if (PersistentSettings == null || PersistentDays == null)
{
_settings = await TrackerService.GetSettingsAsync(_userId);
await LoadMonth();
PersistentSettings = _settings;
PersistentDays = _days;
PersistentSubLabel = _subLabel;
PersistentMonthNet = _monthNet;
PersistentMonthOvertime = _monthOvertime;
PersistentRecordedWorkDays = _recordedWorkDays;
PersistentTotalWorkDays = _totalWorkDays;
PersistentVacationCount = _vacationCount;
PersistentHolidayCount = _holidayCount;
_loading = false;
}
_settings = await TrackerService.GetSettingsAsync(_userId);
await LoadMonth();
_loading = false;
}
private async Task LoadMonth()
{
var workDaysTask = TrackerService.GetMonthAsync(_userId, _year, _month);
var holidaysTask = HolidayService.GetHolidaysAsync(_year, _settings.GermanState);
var vacationsTask = TrackerService.GetVacationDaysAsync(_userId, _year);
await Task.WhenAll(workDaysTask, holidaysTask, vacationsTask);
var workDays = await workDaysTask;
var holidays = await holidaysTask;
var vacations = await vacationsTask;
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();
@@ -273,10 +204,7 @@ else
.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()));
var minMinutes = gross.TotalHours >= 9.0 ? 45 : 30;
var minBreak = TimeSpan.FromMinutes(Math.Max(minMinutes, _settings.MinimumBreakMinutes));
var finalBreak = breaks > minBreak ? breaks : minBreak;
net = gross - finalBreak;
net = gross - breaks;
if (net < TimeSpan.Zero) net = null;
}
@@ -332,16 +260,6 @@ else
await LoadMonth();
}
private void ExportMonthExcel()
{
NavigationManager.NavigateTo($"api/tracker/export/month?year={_year}&month={_month}", forceLoad: true);
}
private void ExportMonthPdf()
{
NavigationManager.NavigateTo($"api/tracker/export/month/pdf?userId={_userId}&year={_year}&month={_month}", forceLoad: true);
}
private static string FormatTs(TimeSpan ts, bool sign = false)
{
var neg = ts < TimeSpan.Zero;
@@ -381,7 +299,7 @@ else
else Chip("Ausstehend", "#CFD8DC");
};
public sealed class MonthDayVm
private sealed class MonthDayVm
{
public DateOnly Date { get; set; }
public TimeOnly? StartTime { get; set; }
@@ -1,14 +1,10 @@
@page "/settings"
@rendermode InteractiveWebAssembly
@rendermode InteractiveServer
@attribute [Authorize]
@inject ITimetrackerService TrackerService
@inject IHolidayService HolidayService
@inject TimetrackerService TrackerService
@inject HolidayService HolidayService
@inject ISnackbar Snackbar
@inject AuthenticationStateProvider AuthStateProvider
@inject IJSRuntime JSRuntime
@inject NavigationManager Nav
@inject HttpClient Http
<PageTitle>Einstellungen Timetracker</PageTitle>
@@ -23,12 +19,13 @@ else
<MudStack Spacing="4">
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
<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: var(--mud-palette-primary-text); font-size:2rem" />
<MudIcon Icon="@Icons.Material.Filled.Settings" Style="color:white; font-size:2rem" />
<MudStack Spacing="0">
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700">Einstellungen</MudText>
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">
<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>
@@ -190,79 +187,6 @@ else
</MudCard>
</MudItem>
@* ── Hilfe & Einführung ── *@
<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.HelpOutline" Color="Color.Primary" />
<MudText Typo="Typo.h6" Style="font-weight:600">Hilfe & Einführung</MudText>
</MudStack>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudStack Spacing="4">
<MudText Typo="Typo.body2" Color="Color.Secondary">
Wenn du die interaktive Einführung (Onboarding Tour) noch einmal sehen möchtest, kannst du sie hier zurücksetzen und neu starten.
</MudText>
<MudButton Variant="Variant.Outlined" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.PlayCircleOutline"
OnClick="RepeatOnboarding"
Style="max-width:250px;">
Onboarding wiederholen
</MudButton>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
@if (_isTenantAdmin)
{
@* ── Firmen-Einstellungen (Branding) ── *@
<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.Business" Color="Color.Primary" />
<MudText Typo="Typo.h6" Style="font-weight:600">Firmen-Einstellungen</MudText>
</MudStack>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudStack Spacing="4">
<MudTextField @bind-Value="_tenantName"
Label="Firmenname"
Variant="Variant.Outlined"
HelperText="Der offizielle Name deiner Firma" />
<MudStack Row="true" Spacing="4">
<MudColorPicker @bind-Text="_tenantPrimaryColor"
Label="Primärfarbe"
Variant="Variant.Outlined"
Style="@($"color: {_tenantPrimaryColor};")"
ColorPickerMode="ColorPickerMode.HEX" />
<MudColorPicker @bind-Text="_tenantSecondaryColor"
Label="Sekundärfarbe"
Variant="Variant.Outlined"
Style="@($"color: {_tenantSecondaryColor};")"
ColorPickerMode="ColorPickerMode.HEX" />
</MudStack>
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Save"
OnClick="SaveTenantSettings"
Style="max-width:250px;">
Firma speichern
</MudButton>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
}
</MudGrid>
@* ── Speichern-Button ── *@
@@ -511,34 +435,8 @@ else
@code {
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
[PersistentState(AllowUpdates = true)]
public AppSettings? PersistentSettings { get; set; }
[PersistentState(AllowUpdates = true)]
public TenantInfo? PersistentTenantInfo { get; set; }
[PersistentState(AllowUpdates = true)]
public List<VacationDay>? PersistentVacationDays { get; set; }
[PersistentState(AllowUpdates = true)]
public List<PublicHoliday>? PersistentHolHolidays { get; set; }
[PersistentState(AllowUpdates = true)]
public string? PersistentTenantName { get; set; }
[PersistentState(AllowUpdates = true)]
public string? PersistentTenantPrimaryColor { get; set; }
[PersistentState(AllowUpdates = true)]
public string? PersistentTenantSecondaryColor { get; set; }
private AppSettings? _settings;
private int _userId;
private bool _isTenantAdmin;
private TenantInfo? _tenantInfo;
private string _tenantName = "";
private string _tenantPrimaryColor = "#0EA5E9";
private string _tenantSecondaryColor = "#0EA5E9";
private DateTime? _flexStartDate
{
get => _settings?.FlexTimeStartDate?.ToDateTime(TimeOnly.MinValue);
@@ -592,63 +490,13 @@ else
protected override async Task OnInitializedAsync()
{
if (PersistentSettings != null)
{
_settings = PersistentSettings;
_tenantInfo = PersistentTenantInfo;
_vacationDays = PersistentVacationDays ?? [];
_holHolidays = PersistentHolHolidays ?? [];
_tenantName = PersistentTenantName ?? "";
_tenantPrimaryColor = PersistentTenantPrimaryColor ?? "#0EA5E9";
_tenantSecondaryColor = PersistentTenantSecondaryColor ?? "#0EA5E9";
}
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return;
_userId = int.Parse(claim.Value);
if (PersistentSettings != null)
{
_isTenantAdmin = authState.User.IsInRole("TenantAdmin") || authState.User.HasClaim("IsTenantAdmin", "true");
}
else
{
_isTenantAdmin = authState.User.IsInRole("TenantAdmin") || authState.User.HasClaim("IsTenantAdmin", "true");
_settings = await TrackerService.GetSettingsAsync(_userId);
if (_isTenantAdmin)
{
try
{
_tenantInfo = await Http.GetFromJsonAsync<TenantInfo>("api/tenant/current");
if (_tenantInfo != null)
{
_tenantName = _tenantInfo.Name ?? "";
_tenantPrimaryColor = _tenantInfo.PrimaryColor ?? "#0EA5E9";
_tenantSecondaryColor = _tenantInfo.SecondaryColor ?? "#0EA5E9";
}
}
catch (Exception ex)
{
Snackbar.Add($"Fehler beim Laden der Firmen-Einstellungen: {ex.Message}", Severity.Error);
}
}
var loadVacationsTask = LoadVacations();
var loadHolidaysTask = HolidayService.GetHolidaysAsync(_holYear, _settings.GermanState);
await Task.WhenAll(loadVacationsTask, loadHolidaysTask);
_holHolidays = await loadHolidaysTask;
PersistentSettings = _settings;
PersistentTenantInfo = _tenantInfo;
PersistentVacationDays = _vacationDays;
PersistentHolHolidays = _holHolidays;
PersistentTenantName = _tenantName;
PersistentTenantPrimaryColor = _tenantPrimaryColor;
PersistentTenantSecondaryColor = _tenantSecondaryColor;
}
_settings = await TrackerService.GetSettingsAsync(_userId);
await LoadVacations();
_holHolidays = await HolidayService.GetHolidaysAsync(_holYear, _settings.GermanState);
}
private async Task LoadVacations()
@@ -726,49 +574,6 @@ else
Snackbar.Add("Feiertag entfernt", Severity.Info);
}
private async Task RepeatOnboarding()
{
try
{
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "showOnboarding", "true");
Snackbar.Add("Onboarding zurückgesetzt. Leite weiter zur Startseite...", Severity.Info);
Nav.NavigateTo("/");
}
catch (Exception ex)
{
Snackbar.Add($"Fehler beim Zurücksetzen des Onboardings: {ex.Message}", Severity.Error);
}
}
private async Task SaveTenantSettings()
{
if (string.IsNullOrWhiteSpace(_tenantName))
{
Snackbar.Add("Bitte einen Firmennamen eingeben.", Severity.Warning);
return;
}
var dto = new TenantSettingsDto
{
Name = _tenantName.Trim(),
PrimaryColor = _tenantPrimaryColor,
SecondaryColor = _tenantSecondaryColor
};
var response = await Http.PostAsJsonAsync("api/tracker/tenant/settings", dto);
if (response.IsSuccessStatusCode)
{
Snackbar.Add("Firmen-Einstellungen gespeichert. Lade Seite neu...", Severity.Success);
await Task.Delay(1000);
Nav.NavigateTo(Nav.Uri, forceLoad: true);
}
else
{
var err = await response.Content.ReadAsStringAsync();
Snackbar.Add($"Fehler beim Speichern: {err}", Severity.Error);
}
}
private static string FormatHours(double hours)
{
var ts = TimeSpan.FromHours(hours);
@@ -1,8 +1,8 @@
@page "/urlaub-maximizer"
@rendermode InteractiveWebAssembly
@rendermode InteractiveServer
@attribute [Authorize]
@inject ITimetrackerService TrackerService
@inject IHolidayService HolidayService
@inject TimetrackerService TrackerService
@inject HolidayService HolidayService
@inject ISnackbar Snackbar
@inject AuthenticationStateProvider AuthStateProvider
@@ -21,7 +21,7 @@ else
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: #C2410C; color:white;">
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" />
@@ -226,27 +226,6 @@ else
@code {
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
[PersistentState(AllowUpdates = true)]
public AppSettings? PersistentSettings { get; set; }
[PersistentState(AllowUpdates = true)]
public Dictionary<DateOnly, string>? PersistentHolidays { get; set; }
[PersistentState(AllowUpdates = true)]
public List<DateOnly>? PersistentVacationList { get; set; }
[PersistentState(AllowUpdates = true)]
public int? PersistentRemainingDays { get; set; }
[PersistentState(AllowUpdates = true)]
public List<Suggestion>? PersistentSuggestions { get; set; }
[PersistentState(AllowUpdates = true)]
public string? PersistentSubLabel { get; set; }
[PersistentState(AllowUpdates = true)]
public int? PersistentYear { get; set; }
private bool _loading = true;
private int _year = DateTime.Today.Year;
private AppSettings _settings = new();
@@ -259,48 +238,19 @@ else
protected override async Task OnInitializedAsync()
{
if (PersistentSettings != null && PersistentSuggestions != null)
{
_settings = PersistentSettings;
_holidays = PersistentHolidays ?? [];
_vacationSet = (PersistentVacationList ?? []).ToHashSet();
_remainingDays = PersistentRemainingDays ?? 0;
_suggestions = PersistentSuggestions;
_subLabel = PersistentSubLabel ?? "";
_year = PersistentYear ?? DateTime.Today.Year;
_loading = false;
}
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return;
_userId = int.Parse(claim.Value);
if (PersistentSettings == null || PersistentSuggestions == null)
{
_settings = await TrackerService.GetSettingsAsync(_userId);
await LoadYear();
PersistentSettings = _settings;
PersistentHolidays = _holidays;
PersistentVacationList = _vacationSet.ToList();
PersistentRemainingDays = _remainingDays;
PersistentSuggestions = _suggestions;
PersistentSubLabel = _subLabel;
PersistentYear = _year;
_loading = false;
}
_settings = await TrackerService.GetSettingsAsync(_userId);
await LoadYear();
_loading = false;
}
private async Task LoadYear()
{
var holidaysTask = HolidayService.GetHolidaysAsync(_year, _settings.GermanState);
var vacationsTask = TrackerService.GetVacationDaysAsync(_userId, _year);
await Task.WhenAll(holidaysTask, vacationsTask);
var holidays = await holidaysTask;
var vacations = await vacationsTask;
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);
@@ -328,7 +278,7 @@ else
// ── Algorithmus ──────────────────────────────────────────────────────
private enum DayKind { Free, WorkAvailable, WorkTaken }
public sealed record Suggestion(
private sealed record Suggestion(
DateOnly SpanStart,
DateOnly SpanEnd,
List<DateOnly> VacationDaysToTake,
@@ -1,10 +1,10 @@
@rendermode InteractiveWebAssembly
@rendermode InteractiveServer
<Router AppAssembly="typeof(timetracker.Client._Imports).Assembly">
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(timetracker.Client.Components.Layout.MainLayout)">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
<NotAuthorized>
<timetracker.Client.Components.RedirectToLogin />
<RedirectToLogin />
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="routeData" Selector="h1" />
@@ -1,4 +1,4 @@
@using System.Net.Http
@using System.Net.Http
@using System.Net.Http.Json
@using System.Security.Claims
@using Microsoft.AspNetCore.Authorization
@@ -10,8 +10,7 @@
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using timetracker
@using timetracker.Client.Components
@using timetracker.Client.Components.Layout
@using timetracker.Components
@using timetracker.Components.Layout
@using timetracker.Data
@using timetracker.Shared
@using MudBlazor
@@ -1,10 +1,9 @@
namespace timetracker.Shared;
namespace timetracker.Data;
public class AppSettings
{
public int Id { get; set; }
public int UserId { get; set; }
public int? TenantId { get; set; }
public double DailyTargetHours { get; set; } = 7.5;
public int MinimumBreakMinutes { get; set; } = 30;
public int VacationDaysPerYear { get; set; } = 30;
+96
View File
@@ -0,0 +1,96 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
namespace timetracker.Data;
public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNotificationService notifier)
{
public async Task<User?> LoginAsync(string username, string password)
{
await using var db = await factory.CreateDbContextAsync();
var user = await db.Users
.FirstOrDefaultAsync(u => u.Username == username);
if (user == null) return null;
return VerifyPassword(password, user.PasswordHash, user.PasswordSalt) ? user : null;
}
public async Task<List<User>> GetAllUsersAsync()
{
await using var db = await factory.CreateDbContextAsync();
return await db.Users
.OrderBy(u => u.Username)
.ToListAsync();
}
public async Task DeleteUserAsync(int userId)
{
await using var db = await factory.CreateDbContextAsync();
var user = await db.Users.FindAsync(userId);
if (user != null)
{
db.Users.Remove(user);
await db.SaveChangesAsync();
await notifier.NotifyUserDeletedAsync(userId);
await notifier.NotifyUsersChangedAsync();
}
}
public async Task<string?> RenameUserAsync(int userId, string newUsername)
{
if (string.IsNullOrWhiteSpace(newUsername) || newUsername.Length < 3)
return "Benutzername muss mindestens 3 Zeichen lang sein.";
await using var db = await factory.CreateDbContextAsync();
if (await db.Users.AnyAsync(u => u.Username == newUsername && u.Id != userId))
return "Benutzername bereits vergeben.";
var user = await db.Users.FindAsync(userId);
if (user == null) return "Benutzer nicht gefunden.";
user.Username = newUsername;
await db.SaveChangesAsync();
await notifier.NotifyUsersChangedAsync();
return null;
}
public async Task<(User? User, string? Error)> RegisterAsync(string username, string password) {
if (string.IsNullOrWhiteSpace(username) || username.Length < 3)
return (null, "Benutzername muss mindestens 3 Zeichen lang sein.");
if (string.IsNullOrWhiteSpace(password) || password.Length < 6)
return (null, "Passwort muss mindestens 6 Zeichen lang sein.");
await using var db = await factory.CreateDbContextAsync();
if (await db.Users.AnyAsync(u => u.Username == username))
return (null, "Benutzername bereits vergeben.");
var (hash, salt) = HashPassword(password);
var user = new User { Username = username, PasswordHash = hash, PasswordSalt = salt };
db.Users.Add(user);
await db.SaveChangesAsync();
await notifier.NotifyUsersChangedAsync();
return (user, null);
}
private static (string hash, string salt) HashPassword(string password)
{
var saltBytes = RandomNumberGenerator.GetBytes(32);
var salt = Convert.ToBase64String(saltBytes);
var hash = ComputeHash(password, salt);
return (hash, salt);
}
private static bool VerifyPassword(string password, string hash, string salt)
=> ComputeHash(password, salt) == hash;
private static string ComputeHash(string password, string salt)
{
var hash = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(password),
Convert.FromBase64String(salt),
iterations: 200_000,
HashAlgorithmName.SHA256,
outputLength: 32);
return Convert.ToBase64String(hash);
}
}
@@ -1,10 +1,9 @@
namespace timetracker.Shared;
namespace timetracker.Data;
public class BreakEntry
{
public int Id { get; set; }
public int WorkDayId { get; set; }
[System.Text.Json.Serialization.JsonIgnore]
public WorkDay WorkDay { get; set; } = null!;
public TimeOnly? StartTime { get; set; }
public TimeOnly? EndTime { get; set; }
@@ -1,11 +1,10 @@
using Microsoft.EntityFrameworkCore;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using timetracker.Shared;
namespace timetracker.Data;
public class HolidayService(IDbContextFactory<TimetrackerDbContext> factory, HttpClient http) : IHolidayService
public class HolidayService(IDbContextFactory<TimetrackerDbContext> factory, HttpClient http)
{
private const string ApiUrl = "https://date.nager.at/api/v3/PublicHolidays/{0}/DE";
@@ -2,7 +2,6 @@
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using timetracker.Data;
@@ -11,16 +10,14 @@ using timetracker.Data;
namespace timetracker.Data.Migrations
{
[DbContext(typeof(TimetrackerDbContext))]
[Migration("20260624204954_AddMultiTenancy")]
partial class AddMultiTenancy
partial class TimetrackerDbContextModelSnapshot : ModelSnapshot
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
modelBuilder.Entity("timetracker.Shared.AppSettings", b =>
modelBuilder.Entity("timetracker.Data.AppSettings", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@@ -41,9 +38,6 @@ namespace timetracker.Data.Migrations
b.Property<int>("MinimumBreakMinutes")
.HasColumnType("INTEGER");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
@@ -76,7 +70,7 @@ namespace timetracker.Data.Migrations
b.ToTable("AppSettings");
});
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
modelBuilder.Entity("timetracker.Data.BreakEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@@ -98,7 +92,7 @@ namespace timetracker.Data.Migrations
b.ToTable("BreakEntries");
});
modelBuilder.Entity("timetracker.Shared.PublicHoliday", b =>
modelBuilder.Entity("timetracker.Data.PublicHoliday", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@@ -119,35 +113,7 @@ namespace timetracker.Data.Migrations
b.ToTable("PublicHolidays");
});
modelBuilder.Entity("timetracker.Shared.Tenant", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsApproved")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Subdomain")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Subdomain")
.IsUnique();
b.ToTable("Tenants");
});
modelBuilder.Entity("timetracker.Shared.User", b =>
modelBuilder.Entity("timetracker.Data.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@@ -161,21 +127,16 @@ namespace timetracker.Data.Migrations
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TenantId");
b.ToTable("Users");
});
modelBuilder.Entity("timetracker.Shared.VacationDay", b =>
modelBuilder.Entity("timetracker.Data.VacationDay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@@ -187,9 +148,6 @@ namespace timetracker.Data.Migrations
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
@@ -198,7 +156,7 @@ namespace timetracker.Data.Migrations
b.ToTable("VacationDays");
});
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
modelBuilder.Entity("timetracker.Data.WorkDay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@@ -213,9 +171,6 @@ namespace timetracker.Data.Migrations
b.Property<TimeOnly?>("StartTime")
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
@@ -224,9 +179,9 @@ namespace timetracker.Data.Migrations
b.ToTable("WorkDays");
});
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
modelBuilder.Entity("timetracker.Data.BreakEntry", b =>
{
b.HasOne("timetracker.Shared.WorkDay", "WorkDay")
b.HasOne("timetracker.Data.WorkDay", "WorkDay")
.WithMany("Breaks")
.HasForeignKey("WorkDayId")
.OnDelete(DeleteBehavior.Cascade)
@@ -235,16 +190,7 @@ namespace timetracker.Data.Migrations
b.Navigation("WorkDay");
});
modelBuilder.Entity("timetracker.Shared.User", b =>
{
b.HasOne("timetracker.Shared.Tenant", "Tenant")
.WithMany()
.HasForeignKey("TenantId");
b.Navigation("Tenant");
});
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
modelBuilder.Entity("timetracker.Data.WorkDay", b =>
{
b.Navigation("Breaks");
});
@@ -1,4 +1,4 @@
namespace timetracker.Shared;
namespace timetracker.Data;
public class PublicHoliday
{
+13
View File
@@ -0,0 +1,13 @@
using Microsoft.EntityFrameworkCore;
namespace timetracker.Data;
public class TimetrackerDbContext(DbContextOptions<TimetrackerDbContext> options) : DbContext(options)
{
public DbSet<User> Users => Set<User>();
public DbSet<WorkDay> WorkDays => Set<WorkDay>();
public DbSet<BreakEntry> BreakEntries => Set<BreakEntry>();
public DbSet<AppSettings> AppSettings => Set<AppSettings>();
public DbSet<VacationDay> VacationDays => Set<VacationDay>();
public DbSet<PublicHoliday> PublicHolidays => Set<PublicHoliday>();
}
@@ -1,9 +1,8 @@
using Microsoft.EntityFrameworkCore;
using timetracker.Shared;
namespace timetracker.Data;
public class TimetrackerService(IDbContextFactory<TimetrackerDbContext> factory) : ITimetrackerService
public class TimetrackerService(IDbContextFactory<TimetrackerDbContext> factory)
{
public async Task<List<WorkDay>> GetWeekAsync(int userId, DateOnly monday)
{
@@ -173,10 +172,7 @@ public class TimetrackerService(IDbContextFactory<TimetrackerDbContext> factory)
.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()));
var minMinutes = gross.TotalHours >= 9.0 ? 45 : 30;
var minBreak = TimeSpan.FromMinutes(Math.Max(minMinutes, settings.MinimumBreakMinutes));
var finalBreak = breakTotal > minBreak ? breakTotal : minBreak;
actual = (gross - finalBreak).TotalHours;
actual = (gross - breakTotal).TotalHours;
if (actual < 0) actual = 0.0;
}
}
@@ -199,144 +195,4 @@ public class TimetrackerService(IDbContextFactory<TimetrackerDbContext> factory)
.OrderBy(w => w.Date)
.ToListAsync();
}
// ── Dashboard ─────────────────────────────────────────────────────────
public async Task<DashboardStatsVm> GetDashboardStatsAsync(int userId, int year, int month)
{
await using var db = await factory.CreateDbContextAsync();
var startDate = new DateOnly(year, month, 1);
var endDate = startDate.AddMonths(1).AddDays(-1);
var workDays = await db.WorkDays
.Include(w => w.Breaks)
.Where(w => w.UserId == userId && w.Date >= startDate && w.Date <= endDate)
.ToListAsync();
var stats = new DashboardStatsVm();
if (workDays.Any(w => w.StartTime.HasValue))
{
var earliest = workDays.Where(w => w.StartTime.HasValue).Min(w => w.StartTime!.Value);
stats.EarliestStart = earliest.ToTimeSpan();
}
if (workDays.Any(w => w.EndTime.HasValue))
{
var latest = workDays.Where(w => w.EndTime.HasValue).Max(w => w.EndTime!.Value);
stats.LatestEnd = latest.ToTimeSpan();
}
var daysWithBreaks = workDays.Where(w => w.Breaks.Any(b => b.StartTime.HasValue && b.EndTime.HasValue)).ToList();
if (daysWithBreaks.Any())
{
var totalBreakTicks = daysWithBreaks.Sum(w =>
w.Breaks.Where(b => b.StartTime.HasValue && b.EndTime.HasValue)
.Sum(b => (b.EndTime!.Value - b.StartTime!.Value).Ticks));
stats.AverageBreak = TimeSpan.FromTicks(totalBreakTicks / daysWithBreaks.Count);
}
var allUserDays = await db.WorkDays
.Where(w => w.UserId == userId && w.StartTime.HasValue && w.EndTime.HasValue)
.OrderBy(w => w.Date)
.Select(w => w.Date)
.ToListAsync();
if (allUserDays.Any())
{
int currentStreak = 1;
int maxStreak = 1;
int tempStreak = 1;
for (int i = 1; i < allUserDays.Count; i++)
{
if (allUserDays[i].DayNumber == allUserDays[i - 1].DayNumber + 1)
{
tempStreak++;
}
else if (allUserDays[i].DayNumber > allUserDays[i - 1].DayNumber + 1)
{
if (tempStreak > maxStreak) maxStreak = tempStreak;
tempStreak = 1;
}
}
if (tempStreak > maxStreak) maxStreak = tempStreak;
stats.LongestStreak = maxStreak;
var today = DateOnly.FromDateTime(DateTime.Today);
var yesterday = today.AddDays(-1);
if (allUserDays.Last() == today || allUserDays.Last() == yesterday)
{
stats.CurrentStreak = tempStreak;
}
else
{
stats.CurrentStreak = 0;
}
}
return stats;
}
public async Task<DashboardChartDataVm> GetDashboardChartDataAsync(int userId, int year)
{
await using var db = await factory.CreateDbContextAsync();
var chartData = new DashboardChartDataVm();
var settings = await GetSettingsAsync(userId);
var workDaysThisYear = await db.WorkDays
.Include(w => w.Breaks)
.Where(w => w.UserId == userId && w.Date.Year == year)
.ToListAsync();
var vacationsThisYear = await db.VacationDays
.Where(v => v.UserId == userId && v.Date.Year == year)
.ToListAsync();
var holidaysThisYear = await db.PublicHolidays
.Where(h => h.Date.Year == year && h.Counties.Contains(settings.GermanState))
.ToListAsync();
chartData.WorkDaysCount = workDaysThisYear.Count(w => w.StartTime.HasValue);
chartData.VacationDaysCount = vacationsThisYear.Count;
chartData.PublicHolidaysCount = holidaysThisYear.Count(h => settings.IsWorkDay(h.Date.DayOfWeek));
for (int month = 1; month <= 12; month++)
{
var daysInMonth = workDaysThisYear.Where(w => w.Date.Month == month).ToList();
if (!daysInMonth.Any(d => d.StartTime.HasValue)) continue;
double monthOvertime = 0;
foreach (var day in daysInMonth)
{
if (!day.StartTime.HasValue || !day.EndTime.HasValue) continue;
var gross = day.EndTime.Value - day.StartTime.Value;
var breakTicks = day.Breaks
.Where(b => b.StartTime.HasValue && b.EndTime.HasValue)
.Sum(b => (b.EndTime!.Value - b.StartTime!.Value).Ticks);
var enteredBreak = TimeSpan.FromTicks(breakTicks);
int minMinutes = gross.TotalHours >= 9.0 ? 45 : 30;
int finalMin = Math.Max(minMinutes, settings.MinimumBreakMinutes);
var minBreak = TimeSpan.FromMinutes(finalMin);
var effectiveBreak = enteredBreak > minBreak ? enteredBreak : minBreak;
var net = gross - effectiveBreak;
var target = TimeSpan.FromHours(settings.DailyTargetHours);
var isHoliday = holidaysThisYear.Any(h => h.Date == day.Date);
var isVacation = vacationsThisYear.Any(v => v.Date == day.Date);
var isWorkDay = settings.IsWorkDay(day.Date.DayOfWeek);
if (!isWorkDay || isHoliday || isVacation) target = TimeSpan.Zero;
monthOvertime += (net - target).TotalHours;
}
var monthName = new DateTime(year, month, 1).ToString("MMM", new System.Globalization.CultureInfo("de-DE"));
chartData.MonthlyOvertime.Add(new MonthlyOvertimeVm { MonthName = monthName, OvertimeHours = monthOvertime });
}
return chartData;
}
}
+1 -5
View File
@@ -1,4 +1,4 @@
namespace timetracker.Shared;
namespace timetracker.Data;
public class User
{
@@ -6,8 +6,4 @@ public class User
public string Username { get; set; } = "";
public string PasswordHash { get; set; } = "";
public string PasswordSalt { get; set; } = "";
public int? TenantId { get; set; }
public Tenant? Tenant { get; set; }
public bool IsTenantAdmin { get; set; }
}
+19
View File
@@ -0,0 +1,19 @@
namespace timetracker.Data;
public class UserNotificationService
{
public event Func<Task>? OnUsersChanged;
public event Func<int, Task>? OnUserDeleted;
public async Task NotifyUsersChangedAsync()
{
if (OnUsersChanged != null)
await OnUsersChanged.Invoke();
}
public async Task NotifyUserDeletedAsync(int userId)
{
if (OnUserDeleted != null)
await OnUserDeleted.Invoke(userId);
}
}
@@ -1,10 +1,9 @@
namespace timetracker.Shared;
namespace timetracker.Data;
public class VacationDay
{
public int Id { get; set; }
public int UserId { get; set; }
public int? TenantId { get; set; }
public DateOnly Date { get; set; }
public string? Note { get; set; }
}
@@ -1,10 +1,9 @@
namespace timetracker.Shared;
namespace timetracker.Data;
public class WorkDay
{
public int Id { get; set; }
public int UserId { get; set; }
public int? TenantId { get; set; }
public DateOnly Date { get; set; }
public TimeOnly? StartTime { get; set; }
public TimeOnly? EndTime { get; set; }
+6 -20
View File
@@ -2,22 +2,11 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Copy project files for restoring dependencies
COPY timetracker.slnx ./
COPY timetracker.Server/timetracker.Server.csproj timetracker.Server/
COPY timetracker.Client/timetracker.Client.csproj timetracker.Client/
COPY timetracker.Shared/timetracker.Shared.csproj timetracker.Shared/
# Keine Unterordner mehr beim Kopieren!
COPY timetracker.csproj ./
RUN dotnet restore timetracker.csproj
# Restore dependencies
RUN dotnet restore timetracker.slnx
# Copy the rest of the source code
COPY timetracker.Server/ timetracker.Server/
COPY timetracker.Client/ timetracker.Client/
COPY timetracker.Shared/ timetracker.Shared/
# Publish
WORKDIR /src/timetracker.Server
COPY . ./
RUN dotnet publish -c Release -o /app/publish
# ── Runtime Stage ─────────────────────────────────────────────────────────────
@@ -25,20 +14,17 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
# Directory for SQLite database
# Verzeichnis für die SQLite-Datenbank
RUN mkdir -p /data
RUN mkdir -p /app/logs
ENV ASPNETCORE_HTTP_PORTS=8080
ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
ENV TIMETRACKER_DB_PATH=/data/timetracker.db
ENV TIMETRACKER_LOG_DIR=/app/logs
ENV EnableHttpsRedirect=false
EXPOSE 8080
VOLUME ["/data"]
VOLUME ["/app/logs"]
ENTRYPOINT ["dotnet", "timetracker.Server.dll"]
ENTRYPOINT ["dotnet", "timetracker.dll"]
+132
View File
@@ -0,0 +1,132 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.EntityFrameworkCore;
using MudBlazor.Services;
using timetracker.Components;
using timetracker.Data;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/login";
options.LogoutPath = "/auth/logout";
options.ExpireTimeSpan = TimeSpan.FromDays(30);
options.SlidingExpiration = true;
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy =>
policy.RequireClaim(System.Security.Claims.ClaimTypes.Name, "marc"));
});
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<timetracker.Data.UserNotificationService>();
builder.Services.AddScoped<AuthService>();
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddMudServices();
builder.Services.AddHttpClient<HolidayService>();
var dbPath = Environment.GetEnvironmentVariable("TIMETRACKER_DB_PATH")
?? Path.Combine(builder.Environment.ContentRootPath, "timetracker.db");
builder.Services.AddDbContextFactory<TimetrackerDbContext>(options =>
options.UseSqlite($"Data Source={dbPath}"));
builder.Services.AddScoped<TimetrackerService>();
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var factory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<TimetrackerDbContext>>();
await using var db = await factory.CreateDbContextAsync();
await db.Database.MigrateAsync();
}
var forwardedHeadersOptions = new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
};
forwardedHeadersOptions.KnownProxies.Clear();
forwardedHeadersOptions.KnownIPNetworks.Clear();
app.UseForwardedHeaders(forwardedHeadersOptions);
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
if (app.Configuration.GetValue("EnableHttpsRedirect", !app.Environment.IsDevelopment()))
{
app.UseHttpsRedirection();
}
// Statische Dateien (inkl. _framework/, _content/) vor Auth bedienen,
// damit Blazor-JS und MudBlazor-CSS nie durch Auth-Middleware geblockt werden
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
// ── Auth-Endpoints ────────────────────────────────────────────────────────────
app.MapPost("/auth/login", async (HttpContext ctx, AuthService authService) =>
{
var form = await ctx.Request.ReadFormAsync();
var username = form["username"].ToString();
var password = form["password"].ToString();
var user = await authService.LoginAsync(username, password);
if (user == null)
return Results.Redirect("/login?error=invalid");
var claims = new[] {
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username)
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity),
new AuthenticationProperties { IsPersistent = true });
return Results.Redirect("/");
}).DisableAntiforgery();
app.MapPost("/auth/register", async (HttpContext ctx, AuthService authService) =>
{
var form = await ctx.Request.ReadFormAsync();
var username = form["username"].ToString();
var password = form["password"].ToString();
var (user, error) = await authService.RegisterAsync(username, password);
if (user == null)
return Results.Redirect($"/login?tab=register&error={Uri.EscapeDataString(error!)}");
var claims = new[] {
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username)
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity),
new AuthenticationProperties { IsPersistent = true });
return Results.Redirect("/");
}).DisableAntiforgery();
app.MapGet("/auth/logout", async (HttpContext ctx) =>
{
await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Results.Redirect("/login");
});
app.Run();
-795
View File
@@ -1,795 +0,0 @@
bash-5.2# ls
timetracker-20260626.log
bash-5.2# cat timetracker-20260626.log
2026-06-26 13:31:25.632 +00:00 [INF] File logging active. Writing logs to /app/logs
2026-06-26 13:31:26.019 +00:00 [WRN] Entity 'WorkDay' has a global query filter defined and is the required end of a relationship with the entity 'BreakEntry'. This may lead to unexpected results when the required entity is filtered out. Either configure the navigation as optional, or define matching query filters for both entities in the navigation. See https://go.microsoft.com/fwlink/?linkid=2131316 for more information.
2026-06-26 13:31:26.249 +00:00 [INF] Executed DbCommand (3ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
2026-06-26 13:31:26.270 +00:00 [INF] Executed DbCommand (2ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT 1
2026-06-26 13:31:26.341 +00:00 [INF] Executed DbCommand (1ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" character varying(150) NOT NULL,
"ProductVersion" character varying(32) NOT NULL,
CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId")
);
2026-06-26 13:31:26.351 +00:00 [INF] Executed DbCommand (0ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
LOCK TABLE "__EFMigrationsHistory" IN ACCESS EXCLUSIVE MODE
2026-06-26 13:31:26.352 +00:00 [INF] Executed DbCommand (0ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
2026-06-26 13:31:26.355 +00:00 [INF] No migrations were applied. The database is already up to date.
2026-06-26 13:31:26.548 +00:00 [INF] Executed DbCommand (1ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT u."Id", u."IsTenantAdmin", u."PasswordHash", u."PasswordSalt", u."TenantId", u."Username"
FROM "Users" AS u
WHERE u."TenantId" IS NULL AND u."Username" = 'marc'
LIMIT 1
2026-06-26 13:31:26.705 +00:00 [ERR] Failed executing DbCommand (6ms) [Parameters=[@p0='?' (DbType = Boolean), @p1='?', @p2='?', @p3='?' (DbType = Int32), @p4='?'], CommandType='"Text"', CommandTimeout='30']
INSERT INTO "Users" ("IsTenantAdmin", "PasswordHash", "PasswordSalt", "TenantId", "Username")
VALUES (@p0, @p1, @p2, @p3, @p4)
RETURNING "Id";
2026-06-26 13:31:26.719 +00:00 [ERR] An exception occurred in the database while saving changes for context type 'timetracker.Data.TimetrackerDbContext'.
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Npgsql.PostgresException (0x80004005): 42804: column "IsTenantAdmin" is of type integer but expression is of type boolean
POSITION: 103
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
Exception data:
Severity: ERROR
SqlState: 42804
MessageText: column "IsTenantAdmin" is of type integer but expression is of type boolean
Hint: You will need to rewrite or cast the expression.
Position: 103
File: parse_target.c
Line: 586
Routine: transformAssignedExpr
--- End of inner exception stack trace ---
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChangesAsync(IList`1 entries, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
at Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.NpgsqlExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Npgsql.PostgresException (0x80004005): 42804: column "IsTenantAdmin" is of type integer but expression is of type boolean
POSITION: 103
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
Exception data:
Severity: ERROR
SqlState: 42804
MessageText: column "IsTenantAdmin" is of type integer but expression is of type boolean
Hint: You will need to rewrite or cast the expression.
Position: 103
File: parse_target.c
Line: 586
Routine: transformAssignedExpr
--- End of inner exception stack trace ---
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChangesAsync(IList`1 entries, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
at Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.NpgsqlExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
2026-06-26 13:31:28.470 +00:00 [INF] File logging active. Writing logs to /app/logs
2026-06-26 13:31:28.848 +00:00 [WRN] Entity 'WorkDay' has a global query filter defined and is the required end of a relationship with the entity 'BreakEntry'. This may lead to unexpected results when the required entity is filtered out. Either configure the navigation as optional, or define matching query filters for both entities in the navigation. See https://go.microsoft.com/fwlink/?linkid=2131316 for more information.
2026-06-26 13:31:29.078 +00:00 [INF] Executed DbCommand (3ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
2026-06-26 13:31:29.100 +00:00 [INF] Executed DbCommand (2ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT 1
2026-06-26 13:31:29.170 +00:00 [INF] Executed DbCommand (1ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" character varying(150) NOT NULL,
"ProductVersion" character varying(32) NOT NULL,
CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId")
);
2026-06-26 13:31:29.180 +00:00 [INF] Executed DbCommand (0ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
LOCK TABLE "__EFMigrationsHistory" IN ACCESS EXCLUSIVE MODE
2026-06-26 13:31:29.182 +00:00 [INF] Executed DbCommand (0ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
2026-06-26 13:31:29.185 +00:00 [INF] No migrations were applied. The database is already up to date.
2026-06-26 13:31:29.376 +00:00 [INF] Executed DbCommand (1ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT u."Id", u."IsTenantAdmin", u."PasswordHash", u."PasswordSalt", u."TenantId", u."Username"
FROM "Users" AS u
WHERE u."TenantId" IS NULL AND u."Username" = 'marc'
LIMIT 1
2026-06-26 13:31:29.537 +00:00 [ERR] Failed executing DbCommand (6ms) [Parameters=[@p0='?' (DbType = Boolean), @p1='?', @p2='?', @p3='?' (DbType = Int32), @p4='?'], CommandType='"Text"', CommandTimeout='30']
INSERT INTO "Users" ("IsTenantAdmin", "PasswordHash", "PasswordSalt", "TenantId", "Username")
VALUES (@p0, @p1, @p2, @p3, @p4)
RETURNING "Id";
2026-06-26 13:31:29.548 +00:00 [ERR] An exception occurred in the database while saving changes for context type 'timetracker.Data.TimetrackerDbContext'.
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Npgsql.PostgresException (0x80004005): 42804: column "IsTenantAdmin" is of type integer but expression is of type boolean
POSITION: 103
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
Exception data:
Severity: ERROR
SqlState: 42804
MessageText: column "IsTenantAdmin" is of type integer but expression is of type boolean
Hint: You will need to rewrite or cast the expression.
Position: 103
File: parse_target.c
Line: 586
Routine: transformAssignedExpr
--- End of inner exception stack trace ---
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChangesAsync(IList`1 entries, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
at Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.NpgsqlExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Npgsql.PostgresException (0x80004005): 42804: column "IsTenantAdmin" is of type integer but expression is of type boolean
POSITION: 103
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
Exception data:
Severity: ERROR
SqlState: 42804
MessageText: column "IsTenantAdmin" is of type integer but expression is of type boolean
Hint: You will need to rewrite or cast the expression.
Position: 103
File: parse_target.c
Line: 586
Routine: transformAssignedExpr
--- End of inner exception stack trace ---
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChangesAsync(IList`1 entries, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
at Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.NpgsqlExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
2026-06-26 13:31:31.379 +00:00 [INF] File logging active. Writing logs to /app/logs
2026-06-26 13:31:31.760 +00:00 [WRN] Entity 'WorkDay' has a global query filter defined and is the required end of a relationship with the entity 'BreakEntry'. This may lead to unexpected results when the required entity is filtered out. Either configure the navigation as optional, or define matching query filters for both entities in the navigation. See https://go.microsoft.com/fwlink/?linkid=2131316 for more information.
2026-06-26 13:31:31.990 +00:00 [INF] Executed DbCommand (3ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
2026-06-26 13:31:32.011 +00:00 [INF] Executed DbCommand (2ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT 1
2026-06-26 13:31:32.082 +00:00 [INF] Executed DbCommand (1ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" character varying(150) NOT NULL,
"ProductVersion" character varying(32) NOT NULL,
CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId")
);
2026-06-26 13:31:32.091 +00:00 [INF] Executed DbCommand (0ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
LOCK TABLE "__EFMigrationsHistory" IN ACCESS EXCLUSIVE MODE
2026-06-26 13:31:32.094 +00:00 [INF] Executed DbCommand (0ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
2026-06-26 13:31:32.097 +00:00 [INF] No migrations were applied. The database is already up to date.
2026-06-26 13:31:32.290 +00:00 [INF] Executed DbCommand (1ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT u."Id", u."IsTenantAdmin", u."PasswordHash", u."PasswordSalt", u."TenantId", u."Username"
FROM "Users" AS u
WHERE u."TenantId" IS NULL AND u."Username" = 'marc'
LIMIT 1
2026-06-26 13:31:32.452 +00:00 [ERR] Failed executing DbCommand (6ms) [Parameters=[@p0='?' (DbType = Boolean), @p1='?', @p2='?', @p3='?' (DbType = Int32), @p4='?'], CommandType='"Text"', CommandTimeout='30']
INSERT INTO "Users" ("IsTenantAdmin", "PasswordHash", "PasswordSalt", "TenantId", "Username")
VALUES (@p0, @p1, @p2, @p3, @p4)
RETURNING "Id";
2026-06-26 13:31:32.462 +00:00 [ERR] An exception occurred in the database while saving changes for context type 'timetracker.Data.TimetrackerDbContext'.
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Npgsql.PostgresException (0x80004005): 42804: column "IsTenantAdmin" is of type integer but expression is of type boolean
POSITION: 103
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
Exception data:
Severity: ERROR
SqlState: 42804
MessageText: column "IsTenantAdmin" is of type integer but expression is of type boolean
Hint: You will need to rewrite or cast the expression.
Position: 103
File: parse_target.c
Line: 586
Routine: transformAssignedExpr
--- End of inner exception stack trace ---
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChangesAsync(IList`1 entries, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
at Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.NpgsqlExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Npgsql.PostgresException (0x80004005): 42804: column "IsTenantAdmin" is of type integer but expression is of type boolean
POSITION: 103
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
Exception data:
Severity: ERROR
SqlState: 42804
MessageText: column "IsTenantAdmin" is of type integer but expression is of type boolean
Hint: You will need to rewrite or cast the expression.
Position: 103
File: parse_target.c
Line: 586
Routine: transformAssignedExpr
--- End of inner exception stack trace ---
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChangesAsync(IList`1 entries, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
at Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.NpgsqlExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
2026-06-26 13:31:34.341 +00:00 [INF] File logging active. Writing logs to /app/logs
2026-06-26 13:31:34.731 +00:00 [WRN] Entity 'WorkDay' has a global query filter defined and is the required end of a relationship with the entity 'BreakEntry'. This may lead to unexpected results when the required entity is filtered out. Either configure the navigation as optional, or define matching query filters for both entities in the navigation. See https://go.microsoft.com/fwlink/?linkid=2131316 for more information.
2026-06-26 13:31:34.964 +00:00 [INF] Executed DbCommand (3ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
2026-06-26 13:31:34.986 +00:00 [INF] Executed DbCommand (2ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT 1
2026-06-26 13:31:35.057 +00:00 [INF] Executed DbCommand (1ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" character varying(150) NOT NULL,
"ProductVersion" character varying(32) NOT NULL,
CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId")
);
2026-06-26 13:31:35.067 +00:00 [INF] Executed DbCommand (0ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
LOCK TABLE "__EFMigrationsHistory" IN ACCESS EXCLUSIVE MODE
2026-06-26 13:31:35.068 +00:00 [INF] Executed DbCommand (0ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
2026-06-26 13:31:35.072 +00:00 [INF] No migrations were applied. The database is already up to date.
2026-06-26 13:31:35.267 +00:00 [INF] Executed DbCommand (1ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT u."Id", u."IsTenantAdmin", u."PasswordHash", u."PasswordSalt", u."TenantId", u."Username"
FROM "Users" AS u
WHERE u."TenantId" IS NULL AND u."Username" = 'marc'
LIMIT 1
2026-06-26 13:31:35.426 +00:00 [ERR] Failed executing DbCommand (7ms) [Parameters=[@p0='?' (DbType = Boolean), @p1='?', @p2='?', @p3='?' (DbType = Int32), @p4='?'], CommandType='"Text"', CommandTimeout='30']
INSERT INTO "Users" ("IsTenantAdmin", "PasswordHash", "PasswordSalt", "TenantId", "Username")
VALUES (@p0, @p1, @p2, @p3, @p4)
RETURNING "Id";
2026-06-26 13:31:35.440 +00:00 [ERR] An exception occurred in the database while saving changes for context type 'timetracker.Data.TimetrackerDbContext'.
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Npgsql.PostgresException (0x80004005): 42804: column "IsTenantAdmin" is of type integer but expression is of type boolean
POSITION: 103
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
Exception data:
Severity: ERROR
SqlState: 42804
MessageText: column "IsTenantAdmin" is of type integer but expression is of type boolean
Hint: You will need to rewrite or cast the expression.
Position: 103
File: parse_target.c
Line: 586
Routine: transformAssignedExpr
--- End of inner exception stack trace ---
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChangesAsync(IList`1 entries, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
at Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.NpgsqlExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Npgsql.PostgresException (0x80004005): 42804: column "IsTenantAdmin" is of type integer but expression is of type boolean
POSITION: 103
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
Exception data:
Severity: ERROR
SqlState: 42804
MessageText: column "IsTenantAdmin" is of type integer but expression is of type boolean
Hint: You will need to rewrite or cast the expression.
Position: 103
File: parse_target.c
Line: 586
Routine: transformAssignedExpr
--- End of inner exception stack trace ---
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChangesAsync(IList`1 entries, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
at Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.NpgsqlExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
2026-06-26 13:31:37.974 +00:00 [INF] File logging active. Writing logs to /app/logs
2026-06-26 13:31:38.451 +00:00 [WRN] Entity 'WorkDay' has a global query filter defined and is the required end of a relationship with the entity 'BreakEntry'. This may lead to unexpected results when the required entity is filtered out. Either configure the navigation as optional, or define matching query filters for both entities in the navigation. See https://go.microsoft.com/fwlink/?linkid=2131316 for more information.
2026-06-26 13:31:38.698 +00:00 [INF] Executed DbCommand (3ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
2026-06-26 13:31:38.721 +00:00 [INF] Executed DbCommand (2ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT 1
2026-06-26 13:31:38.792 +00:00 [INF] Executed DbCommand (1ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" character varying(150) NOT NULL,
"ProductVersion" character varying(32) NOT NULL,
CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId")
);
2026-06-26 13:31:38.802 +00:00 [INF] Executed DbCommand (0ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
LOCK TABLE "__EFMigrationsHistory" IN ACCESS EXCLUSIVE MODE
2026-06-26 13:31:38.803 +00:00 [INF] Executed DbCommand (0ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
2026-06-26 13:31:38.807 +00:00 [INF] No migrations were applied. The database is already up to date.
2026-06-26 13:31:39.004 +00:00 [INF] Executed DbCommand (1ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT u."Id", u."IsTenantAdmin", u."PasswordHash", u."PasswordSalt", u."TenantId", u."Username"
FROM "Users" AS u
WHERE u."TenantId" IS NULL AND u."Username" = 'marc'
LIMIT 1
2026-06-26 13:31:39.163 +00:00 [ERR] Failed executing DbCommand (6ms) [Parameters=[@p0='?' (DbType = Boolean), @p1='?', @p2='?', @p3='?' (DbType = Int32), @p4='?'], CommandType='"Text"', CommandTimeout='30']
INSERT INTO "Users" ("IsTenantAdmin", "PasswordHash", "PasswordSalt", "TenantId", "Username")
VALUES (@p0, @p1, @p2, @p3, @p4)
RETURNING "Id";
2026-06-26 13:31:39.176 +00:00 [ERR] An exception occurred in the database while saving changes for context type 'timetracker.Data.TimetrackerDbContext'.
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Npgsql.PostgresException (0x80004005): 42804: column "IsTenantAdmin" is of type integer but expression is of type boolean
POSITION: 103
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
Exception data:
Severity: ERROR
SqlState: 42804
MessageText: column "IsTenantAdmin" is of type integer but expression is of type boolean
Hint: You will need to rewrite or cast the expression.
Position: 103
File: parse_target.c
Line: 586
Routine: transformAssignedExpr
--- End of inner exception stack trace ---
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChangesAsync(IList`1 entries, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
at Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.NpgsqlExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Npgsql.PostgresException (0x80004005): 42804: column "IsTenantAdmin" is of type integer but expression is of type boolean
POSITION: 103
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
Exception data:
Severity: ERROR
SqlState: 42804
MessageText: column "IsTenantAdmin" is of type integer but expression is of type boolean
Hint: You will need to rewrite or cast the expression.
Position: 103
File: parse_target.c
Line: 586
Routine: transformAssignedExpr
--- End of inner exception stack trace ---
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChangesAsync(IList`1 entries, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
at Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.NpgsqlExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
2026-06-26 13:31:41.413 +00:00 [INF] File logging active. Writing logs to /app/logs
2026-06-26 13:31:41.805 +00:00 [WRN] Entity 'WorkDay' has a global query filter defined and is the required end of a relationship with the entity 'BreakEntry'. This may lead to unexpected results when the required entity is filtered out. Either configure the navigation as optional, or define matching query filters for both entities in the navigation. See https://go.microsoft.com/fwlink/?linkid=2131316 for more information.
2026-06-26 13:31:42.040 +00:00 [INF] Executed DbCommand (4ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
2026-06-26 13:31:42.062 +00:00 [INF] Executed DbCommand (2ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT 1
2026-06-26 13:31:42.133 +00:00 [INF] Executed DbCommand (0ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" character varying(150) NOT NULL,
"ProductVersion" character varying(32) NOT NULL,
CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId")
);
2026-06-26 13:31:42.143 +00:00 [INF] Executed DbCommand (0ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
LOCK TABLE "__EFMigrationsHistory" IN ACCESS EXCLUSIVE MODE
2026-06-26 13:31:42.144 +00:00 [INF] Executed DbCommand (0ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
2026-06-26 13:31:42.148 +00:00 [INF] No migrations were applied. The database is already up to date.
2026-06-26 13:31:42.342 +00:00 [INF] Executed DbCommand (2ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT u."Id", u."IsTenantAdmin", u."PasswordHash", u."PasswordSalt", u."TenantId", u."Username"
FROM "Users" AS u
WHERE u."TenantId" IS NULL AND u."Username" = 'marc'
LIMIT 1
2026-06-26 13:31:42.501 +00:00 [ERR] Failed executing DbCommand (6ms) [Parameters=[@p0='?' (DbType = Boolean), @p1='?', @p2='?', @p3='?' (DbType = Int32), @p4='?'], CommandType='"Text"', CommandTimeout='30']
INSERT INTO "Users" ("IsTenantAdmin", "PasswordHash", "PasswordSalt", "TenantId", "Username")
VALUES (@p0, @p1, @p2, @p3, @p4)
RETURNING "Id";
2026-06-26 13:31:42.514 +00:00 [ERR] An exception occurred in the database while saving changes for context type 'timetracker.Data.TimetrackerDbContext'.
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Npgsql.PostgresException (0x80004005): 42804: column "IsTenantAdmin" is of type integer but expression is of type boolean
POSITION: 103
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
Exception data:
Severity: ERROR
SqlState: 42804
MessageText: column "IsTenantAdmin" is of type integer but expression is of type boolean
Hint: You will need to rewrite or cast the expression.
Position: 103
File: parse_target.c
Line: 586
Routine: transformAssignedExpr
--- End of inner exception stack trace ---
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChangesAsync(IList`1 entries, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
at Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.NpgsqlExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Npgsql.PostgresException (0x80004005): 42804: column "IsTenantAdmin" is of type integer but expression is of type boolean
POSITION: 103
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
Exception data:
Severity: ERROR
SqlState: 42804
MessageText: column "IsTenantAdmin" is of type integer but expression is of type boolean
Hint: You will need to rewrite or cast the expression.
Position: 103
File: parse_target.c
Line: 586
Routine: transformAssignedExpr
--- End of inner exception stack trace ---
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChangesAsync(IList`1 entries, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
at Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.NpgsqlExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
2026-06-26 13:31:46.331 +00:00 [INF] File logging active. Writing logs to /app/logs
Und logs vom DB container:
2026-06-26 13:31:42.498 UTC [32] HINT: You will need to rewrite or cast the expression.
2026-06-26 13:31:42.498 UTC [32] STATEMENT: INSERT INTO "Users" ("IsTenantAdmin", "PasswordHash", "PasswordSalt", "TenantId", "Username")
VALUES ($1, $2, $3, $4, $5)
RETURNING "Id"
2026-06-26 13:31:47.069 UTC [33] ERROR: syntax error at or near "public" at character 68
2026-06-26 13:31:47.069 UTC [33] QUERY: ALTER TABLE public."Users" ALTER COLUMN "Id" SET DEFAULT nextval(''public."Users_Id_seq"'')
2026-06-26 13:31:47.069 UTC [33] CONTEXT: PL/pgSQL function inline_code_block line 12 at EXECUTE
2026-06-26 13:31:47.069 UTC [33] STATEMENT:
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'Users'
AND column_name = 'Id'
) THEN
EXECUTE 'CREATE SEQUENCE IF NOT EXISTS public."Users_Id_seq"';
EXECUTE 'ALTER SEQUENCE public."Users_Id_seq" OWNED BY public."Users"."Id"';
EXECUTE 'ALTER TABLE public."Users" ALTER COLUMN "Id" SET DEFAULT nextval(''''public."Users_Id_seq"'''')';
EXECUTE 'SELECT setval(''''public."Users_Id_seq"'''', COALESCE((SELECT MAX("Id") FROM public."Users"), 0) + 1, false)';

END IF;
END $$
2026-06-26 13:31:47.407 UTC [33] ERROR: column "IsTenantAdmin" is of type integer but expression is of type boolean at character 103
2026-06-26 13:31:47.407 UTC [33] HINT: You will need to rewrite or cast the expression.
2026-06-26 13:31:47.407 UTC [33] STATEMENT: INSERT INTO "Users" ("IsTenantAdmin", "PasswordHash", "PasswordSalt", "TenantId", "Username")
VALUES ($1, $2, $3, $4, $5)
RETURNING "Id"
2026-06-26 13:31:55.406 UTC [34] ERROR: syntax error at or near "public" at character 68
2026-06-26 13:31:55.406 UTC [34] QUERY: ALTER TABLE public."Users" ALTER COLUMN "Id" SET DEFAULT nextval(''public."Users_Id_seq"'')
2026-06-26 13:31:55.406 UTC [34] CONTEXT: PL/pgSQL function inline_code_block line 12 at EXECUTE
2026-06-26 13:31:55.406 UTC [34] STATEMENT:
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'Users'
AND column_name = 'Id'
) THEN
EXECUTE 'CREATE SEQUENCE IF NOT EXISTS public."Users_Id_seq"';
EXECUTE 'ALTER SEQUENCE public."Users_Id_seq" OWNED BY public."Users"."Id"';
EXECUTE 'ALTER TABLE public."Users" ALTER COLUMN "Id" SET DEFAULT nextval(''''public."Users_Id_seq"'''')';
EXECUTE 'SELECT setval(''''public."Users_Id_seq"'''', COALESCE((SELECT MAX("Id") FROM public."Users"), 0) + 1, false)';

END IF;
END $$
2026-06-26 13:31:55.737 UTC [34] ERROR: column "IsTenantAdmin" is of type integer but expression is of type boolean at character 103
2026-06-26 13:31:55.737 UTC [34] HINT: You will need to rewrite or cast the expression.
2026-06-26 13:31:55.737 UTC [34] STATEMENT: INSERT INTO "Users" ("IsTenantAdmin", "PasswordHash", "PasswordSalt", "TenantId", "Username")
VALUES ($1, $2, $3, $4, $5)
RETURNING "Id"
2026-06-26 13:32:09.909 UTC [35] ERROR: syntax error at or near "public" at character 68
2026-06-26 13:32:09.909 UTC [35] QUERY: ALTER TABLE public."Users" ALTER COLUMN "Id" SET DEFAULT nextval(''public."Users_Id_seq"'')
2026-06-26 13:32:09.909 UTC [35] CONTEXT: PL/pgSQL function inline_code_block line 12 at EXECUTE
2026-06-26 13:32:09.909 UTC [35] STATEMENT:
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'Users'
AND column_name = 'Id'
) THEN
EXECUTE 'CREATE SEQUENCE IF NOT EXISTS public."Users_Id_seq"';
EXECUTE 'ALTER SEQUENCE public."Users_Id_seq" OWNED BY public."Users"."Id"';
EXECUTE 'ALTER TABLE public."Users" ALTER COLUMN "Id" SET DEFAULT nextval(''''public."Users_Id_seq"'''')';
EXECUTE 'SELECT setval(''''public."Users_Id_seq"'''', COALESCE((SELECT MAX("Id") FROM public."Users"), 0) + 1, false)';

END IF;
END $$
2026-06-26 13:32:10.239 UTC [35] ERROR: column "IsTenantAdmin" is of type integer but expression is of type boolean at character 103
2026-06-26 13:32:10.239 UTC [35] HINT: You will need to rewrite or cast the expression.
2026-06-26 13:32:10.239 UTC [35] STATEMENT: INSERT INTO "Users" ("IsTenantAdmin", "PasswordHash", "PasswordSalt", "TenantId", "Username")
VALUES ($1, $2, $3, $4, $5)
RETURNING "Id"
2026-06-26 13:32:37.184 UTC [36] ERROR: syntax error at or near "public" at character 68
2026-06-26 13:32:37.184 UTC [36] QUERY: ALTER TABLE public."Users" ALTER COLUMN "Id" SET DEFAULT nextval(''public."Users_Id_seq"'')
2026-06-26 13:32:37.184 UTC [36] CONTEXT: PL/pgSQL function inline_code_block line 12 at EXECUTE
2026-06-26 13:32:37.184 UTC [36] STATEMENT:
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'Users'
AND column_name = 'Id'
) THEN
EXECUTE 'CREATE SEQUENCE IF NOT EXISTS public."Users_Id_seq"';
EXECUTE 'ALTER SEQUENCE public."Users_Id_seq" OWNED BY public."Users"."Id"';
EXECUTE 'ALTER TABLE public."Users" ALTER COLUMN "Id" SET DEFAULT nextval(''''public."Users_Id_seq"'''')';
EXECUTE 'SELECT setval(''''public."Users_Id_seq"'''', COALESCE((SELECT MAX("Id") FROM public."Users"), 0) + 1, false)';

END IF;
END $$
2026-06-26 13:32:37.515 UTC [36] ERROR: column "IsTenantAdmin" is of type integer but expression is of type boolean at character 103
2026-06-26 13:32:37.515 UTC [36] HINT: You will need to rewrite or cast the expression.
2026-06-26 13:32:37.515 UTC [36] STATEMENT: INSERT INTO "Users" ("IsTenantAdmin", "PasswordHash", "PasswordSalt", "TenantId", "Username")
VALUES ($1, $2, $3, $4, $5)
RETURNING "Id"
+8
View File
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
+6 -38
View File
@@ -1,54 +1,22 @@
version: '3.8'
services:
db:
image: postgres:16-alpine
container_name: timetracker-db
restart: always
environment:
POSTGRES_USER: timetracker_user
POSTGRES_PASSWORD: SecretPassword123
POSTGRES_DB: timetracker
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
timetracker:
build:
context: .
dockerfile: timetracker.Server/Dockerfile
dockerfile: Dockerfile
container_name: timetracker-app
restart: always
ports:
- "8090:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Production
- ASPNETCORE_HTTP_PORTS=8080
- DB_PROVIDER=PostgreSQL
- ConnectionStrings__DefaultConnection=Host=db;Database=timetracker;Username=timetracker_user;Password=SecretPassword123;
- ASPNETCORE_HTTP_PORTS=8080 # Lassen wir so, da intern völlig okay
- TIMETRACKER_DB_PATH=/data/timetracker.db
- EnableHttpsRedirect=false
- TIMETRACKER_LOG_DIR=/app/logs
- TenantSettings__BaseDomain=timetracker.marc-wieland.de
volumes:
- dpkeys:/home/app/.aspnet/DataProtection-Keys
- applogs:/app/logs
depends_on:
- db
logshell:
image: bash:5.2
container_name: timetracker-logshell
restart: unless-stopped
working_dir: /logs
command: ["bash", "-lc", "tail -f /dev/null"]
volumes:
- applogs:/logs
- timetracker_data:/data
volumes:
pgdata:
name: timetracker_pgdata_fresh
dpkeys:
name: timetracker_dpkeys
applogs:
name: timetracker_logs
timetracker_data:
name: timetracker_prod_data
-73
View File
@@ -1,73 +0,0 @@
# Brainstorming & Zukunfts-Ideen für den Timetracker
Der Timetracker ist durch die Umstellung auf eine Hosted Blazor WebAssembly (.NET 10) Architektur mit PostgreSQL-Unterstützung extrem solide und zukunftssicher aufgestellt. Hier sind einige spannende, innovative Produktideen, um das Tool auf das nächste Level zu heben:
---
## 🚀 1. PWA-Support (Progressive Web App) und Offline-Modus
* **Offline-Erfassung**: Da die App in Blazor WebAssembly im Browser läuft, lässt sie sich leicht zu einer PWA umrüsten. Zeiterfassungen könnten lokal im `IndexedDB`-Speicher des Browsers gesichert werden, wenn keine Internetverbindung besteht, und bei Wiederverbindung automatisch mit der PostgreSQL-Datenbank synchronisiert werden.
* **Mobiles App-Feeling**: Installation direkt auf dem Homescreen (Android/iOS) ohne App-Store-Zwang.
* **Web-Push Notifications**: Tägliche Erinnerungen, falls man vergisst, sich auszustempeln oder Pausen einzutragen.
---
## 🤖 2. KI-Assistent & Smart-Suggestions
* **Kalender- & Git-Sync**: Einbindung einer KI-Schnittstelle, die Zugriff auf Terminkalender (Google/Outlook) oder lokale Git-Commits hat. Sie schlägt am Ende des Tages automatisch passende Buchungen vor (z. B. *"Du hast von 10-11 Uhr am Feature X gearbeitet. Möchtest du das buchen?"*).
* **Ausreißer-Erkennung**: KI-gestützte Warnungen, wenn Zeiten untypisch erfasst wurden (z. B. 12 Stunden gearbeitet ohne Pause).
---
## 📅 3. Kalender-Integration & Feiertags-Automatisierung
* **Direktes Feiertags-Abonnement**: Automatische Erkennung des Bundeslandes und Import der Feiertage ohne manuellen Klick auf "Von API laden" zu Beginn des Jahres.
* **Kalender-Export (iCal)**: Generierung eines persönlichen iCal-Feeds, damit der User seine gebuchten Urlaubstage und Überstunden in seinem primären Kalender (Google Calendar, Apple Calendar) sieht.
---
## 👥 4. Team-Features & Urlaubsplaner
* **Wer-ist-da-Dashboard**: Ein Live-Statusboard für Teams, auf dem man sieht, wer gerade eingestempelt ist, wer Pause macht oder wer heute Urlaub hat/krankgeschrieben ist.
* **Gemeinsamer Urlaubskalender**: Ein Kalender für das gesamte Team, um Urlaubsüberschneidungen direkt zu erkennen und Freigabeprozesse durch Admins zu ermöglichen.
* **Rollenkonzepte**: Einführung von Teamleitern (können Zeiten der Mitglieder freigeben) und Standard-Mitarbeitern.
---
## 📊 5. Reporting & Abrechnung (Billing)
* **Rechnungserstellung**: Verknüpfung von erfassten Zeiten mit Stundensätzen, um mit einem Klick professionelle PDF-Rechnungen an Kunden zu erstellen.
* **Auswertungen**: Interaktive Diagramme (z.B. mittels MudBlazor Charts) zur Analyse von Arbeitszeiten, Überstundenentwicklung und Krankheitstagen über das Jahr verteilt.
---
## 🔌 6. Integrationen (Jira, GitHub, Slack)
* **Git-Hooks / CLI**: Automatisches Starten/Stoppen von Timern per CLI oder bei Git-Commits.
* **Slack / Teams Chatbot**: Schnelles Ein- und Ausstempeln direkt aus dem Chat-Client heraus über Befehle wie `/track start` oder `/track pause`.
---
## 🔒 7. Sicherheit & DSGVO
* **Audit Logs**: Transparente Historie darüber, wer wann welche Zeiten geändert hat (besonders wichtig bei Prüfungen).
* **Automatisches Löschkonzept**: Möglichkeit zur Einhaltung von Löschfristen erfasster Daten nach Beendigung des Arbeitsverhältnisses.
---
## ✉️ 8. E-Mail-Verifizierung & Inhouse-Mailserver
* **Sicherer Registrierungs-Flow**: E-Mail-Verifizierung bei Neuregistrierung mittels eines 6-stelligen, zeitlich begrenzten Bestätigungscodes. Erst nach Eingabe des Codes wird das Benutzerkonto aktiviert und der Login erlaubt.
* **Inhouse SMTP-Lösungen**:
* **Lokale Entwicklung**: Einbindung eines SMTP-Sandboxes wie **Mailpit** (oder MailHog) direkt in die `docker-compose.yml`, um E-Mails sicher lokal zu fangen und im Browser zu sichten.
* **Produktion**: Konfiguration für inhouse SMTP-Relays oder Aufbau eines vollverschlüsselten **docker-mailserver** Containers für den komplett eigenständigen, datenschutzkonformen E-Mail-Versand.
---
## 🏢 9. B2B-Fähigkeit & Azure AD (Entra ID) Integration
* **Generisches Single Sign-On (SSO)**: Integration von OpenID Connect (OIDC) über Umgebungsvariablen. Dadurch können Unternehmen das Tool nahtlos an Microsoft Entra ID (Azure AD), Okta, Keycloak oder Google Workspace anbinden.
* **White-Labeling & Customizing (Single-Tenant)**: Möglichkeit, Firmenlogos, Firmennamen und Standardeinstellungen (z.B. Feiertags-Bundesland) pro Docker-Compose-Instanz flexibel anzupassen.
* **Optionale SaaS-Erweiterung (Multi-Tenant)**: Zukünftiger Ausbau für mehrmandantenfähigen Betrieb mit Datenisolation auf Tabellenebene (`TenantId`), dynamischer SSO-Zuordnung per E-Mail-Domain und zentraler Rechnungsabwicklung (z.B. Stripe).
---
## 💬 10. Gamifizierter Assistent & KI-Chatbot
* **Regelbasierter Gamification-Widget (Quick-Win)**: Ein kleiner, charismatischer Assistent/Avatar im UI, der basierend auf den aktuellen Arbeitsstatistiken motivierende, freche oder informative Sprüche anzeigt (z.B. bei vielen Überstunden, fehlenden Pausen oder anstehendem Urlaub).
* **LLM-gestützter KI-Assistent (Smart-Assistant)**:
* **Analysen**: Ein Chat-Fenster, in dem der Benutzer Fragen zu seinen Arbeitszeiten stellen kann (z.B. *"Wie viele Überstunden habe ich diesen Monat angesammelt?"* oder *"Wann habe ich letzte Woche Dienstag angefangen?"*).
* **Interaktion & Aktionen**: Die KI kann durch Function Calling direkt Aktionen ausführen (z.B. *"Trage mir für morgen 8 Stunden Arbeit ein"* oder *"Trage Urlaub für KW 28 ein"*).
* **Technologie**: Anbindung über ein einfaches HttpClient-Modul an die Google Gemini API, OpenAI oder lokal über ein Ollama-Docker-Container.
-16
View File
@@ -1,16 +0,0 @@
@echo off
setlocal
echo === 1. Bereinige alte Build-Dateien ===
dotnet clean
if errorlevel 1 exit /b %errorlevel%
echo === 2. Baue die gesamte Solution ===
dotnet build
if errorlevel 1 exit /b %errorlevel%
echo === 3. Starte den Server ^& oeffne Browser ===
start "timetracker-browser" powershell -NoProfile -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:5065'"
dotnet run --project timetracker.Server\timetracker.Server.csproj
exit /b %errorlevel%
-16
View File
@@ -1,16 +0,0 @@
#!/bin/bash
# Exit on error
set -e
echo "=== 1. Bereinige alte Build-Dateien ==="
dotnet clean
echo "=== 2. Baue die gesamte Solution ==="
dotnet build
echo "=== 3. Starte den Server & öffne Browser ==="
# Browser parallel nach kurzem Delay öffnen
(sleep 3 && open http://localhost:5065) &
dotnet run --project timetracker.Server/timetracker.Server.csproj
-3
View File
@@ -1,3 +0,0 @@
using System;
using PdfSharp.Fonts;
class Program { static void Main() { Console.WriteLine("Hello"); } }
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

@@ -1,297 +0,0 @@
@inject ITimetrackerService TrackerService
@inject AuthenticationStateProvider AuthStateProvider
@inject IJSRuntime JS
@using System.Security.Claims
@using timetracker.Shared
@if (_isOpen)
{
<div class="timebot-chat-window">
<!-- Chat Header -->
<div class="timebot-header">
<div class="timebot-header-info">
<img src="/timebot.png" class="timebot-header-avatar" alt="Timebot" />
<div class="timebot-header-text">
<span class="timebot-header-title">Timebot</span>
<span class="timebot-header-status"><span class="status-dot"></span> Online</span>
</div>
</div>
<button class="timebot-close-btn" @onclick="ToggleChat" aria-label="Schließen">
<MudIcon Icon="@Icons.Material.Filled.Close" Size="Size.Small" Style="color: white;" />
</button>
</div>
<!-- Chat Body -->
<div class="timebot-body" id="timebot-body-el">
@foreach (var msg in _messages)
{
<div class="timebot-msg-row @(msg.Sender == "User" ? "user-row" : "bot-row")">
@if (msg.Sender == "Bot")
{
<img src="/timebot.png" class="timebot-msg-avatar" alt="Timebot" />
}
<div class="timebot-msg-bubble @(msg.Sender == "User" ? "user-bubble" : "bot-bubble")">
@if (msg.IsTyping)
{
<div class="typing-indicator">
<span></span><span></span><span></span>
</div>
}
else
{
@((MarkupString)FormatMessageText(msg.Content))
}
</div>
</div>
}
</div>
<!-- Suggestion Chips -->
<div class="timebot-suggestions">
<button @onclick="@(() => AskQuestion("Überstunden"))">📊 Überstunden</button>
<button @onclick="@(() => AskQuestion("Woche"))">⏱️ Wochenarbeitszeit</button>
<button @onclick="@(() => AskQuestion("Urlaub"))">🏖️ Urlaubs-Maximizer</button>
<button @onclick="@(() => AskQuestion("Hilfe"))">💡 Hilfe</button>
</div>
<!-- Chat Footer -->
<div class="timebot-footer">
<input type="text" @bind="_userInput" @bind:event="oninput" @onkeypress="HandleKeyPress" placeholder="Schreibe eine Nachricht..." class="timebot-input" />
<button class="timebot-send-btn" @onclick="SendMessage" disabled="@(string.IsNullOrWhiteSpace(_userInput))">
<MudIcon Icon="@Icons.Material.Filled.Send" Style="color: #3F51B5;" />
</button>
</div>
</div>
}
<!-- Floating Button -->
<div class="timebot-launcher @(_isOpen ? "open" : "")" @onclick="ToggleChat">
<img src="/timebot.png" alt="Timebot Launcher" />
@if (_unreadCount > 0 && !_isOpen)
{
<span class="timebot-badge">@_unreadCount</span>
}
</div>
@code {
private bool _isOpen = false;
private string _userInput = "";
private int _unreadCount = 1;
private List<ChatMessage> _messages = new();
private int _userId;
private string _username = "Marc";
private AppSettings _settings = new();
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim != null)
{
_userId = int.Parse(claim.Value);
_username = authState.User.Identity?.Name ?? "Marc";
if (!string.IsNullOrEmpty(_username))
{
_username = char.ToUpper(_username[0]) + _username.Substring(1);
}
_settings = await TrackerService.GetSettingsAsync(_userId);
}
_messages.Add(new ChatMessage
{
Sender = "Bot",
Content = $"Hallo {_username}! 👋 Ich bin dein Timebot. Ich habe ein Auge auf deine Zeiten geworfen.\n\nWie kann ich dir heute helfen?",
Timestamp = DateTime.Now
});
}
private void ToggleChat()
{
_isOpen = !_isOpen;
if (_isOpen)
{
_unreadCount = 0;
_ = ScrollToBottom();
}
}
private async Task HandleKeyPress(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_userInput))
{
await SendMessage();
}
}
private async Task SendMessage()
{
if (string.IsNullOrWhiteSpace(_userInput)) return;
var text = _userInput;
_userInput = "";
_messages.Add(new ChatMessage { Sender = "User", Content = text, Timestamp = DateTime.Now });
await ScrollToBottom();
await RespondTo(text);
}
private async Task AskQuestion(string key)
{
string userQuestion = "";
if (key == "Überstunden") userQuestion = "Wie viele Überstunden habe ich?";
else if (key == "Woche") userQuestion = "Wie sieht meine Woche aus?";
else if (key == "Urlaub") userQuestion = "Wie hilft mir der Urlaubs-Maximizer?";
else if (key == "Hilfe") userQuestion = "Welche Fragen kann ich dir stellen?";
_messages.Add(new ChatMessage { Sender = "User", Content = userQuestion, Timestamp = DateTime.Now });
await ScrollToBottom();
await RespondTo(key);
}
private async Task RespondTo(string query)
{
// Add typing indicator
var typingMsg = new ChatMessage { Sender = "Bot", IsTyping = true, Timestamp = DateTime.Now };
_messages.Add(typingMsg);
await ScrollToBottom();
// Simulate thinking/typing delay
await Task.Delay(1000);
_messages.Remove(typingMsg);
string responseText = "";
var normalized = query.ToLower();
if (normalized.Contains("überstunden") || normalized == "überstunden" || normalized.Contains("saldo") || normalized.Contains("gleitzeit"))
{
var settings = await TrackerService.GetSettingsAsync(_userId);
var overtime = await TrackerService.GetTotalOvertimeAsync(_userId, settings);
var overtimeStr = FormatTs(overtime, sign: true);
if (overtime >= TimeSpan.Zero)
{
responseText = $"Dein Gleitzeitkonto sieht super aus! Du hast aktuell **{overtimeStr} Std.** auf deinem Gleitzeitkonto angesammelt.\n\nSehr stark! Hast du mit der Extrazeit schon etwas vor oder bist du einfach nur voll am Hustlen? 🚀🔥";
}
else
{
responseText = $"Dein Gleitzeitkonto steht aktuell bei **{overtimeStr} Std.**\n\nDu bist also ein kleines bisschen im Minus. Aber kein Grund zur Sorge: Das lässt sich mit ein paar Extra-Minuten in den nächsten Tagen leicht wieder ausgleichen! 💪";
}
}
else if (normalized.Contains("woche") || normalized == "woche" || normalized.Contains("arbeitszeit") || normalized.Contains("stunden"))
{
var today = DateOnly.FromDateTime(DateTime.Today);
var monday = GetMonday(today);
var settings = await TrackerService.GetSettingsAsync(_userId);
var weekDays = await TrackerService.GetWeekAsync(_userId, monday);
double workedHours = 0;
double targetHours = 0;
foreach (var date in Enumerable.Range(0, 7).Select(i => monday.AddDays(i)))
{
bool isWorkDay = settings.IsWorkDay(date.DayOfWeek);
if (isWorkDay)
{
targetHours += settings.DailyTargetHours;
}
var wd = weekDays.FirstOrDefault(d => d.Date == date);
if (wd != null && wd.StartTime != null && wd.EndTime != null)
{
var gross = wd.EndTime.Value.ToTimeSpan() - wd.StartTime.Value.ToTimeSpan();
if (gross > TimeSpan.Zero)
{
var breakTotal = 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()));
workedHours += (gross - breakTotal).TotalHours;
}
}
}
var workedTs = TimeSpan.FromHours(workedHours);
var targetTs = TimeSpan.FromHours(targetHours);
var percent = targetHours > 0 ? (int)Math.Round(workedHours / targetHours * 100) : 0;
responseText = $"Diese Woche hast du bereits **{FormatTs(workedTs)}** von geplanten **{FormatTs(targetTs)}** gearbeitet.\n\nDu hast diese Woche also schon **{percent}%** deines Solls erfüllt. Weiter so! ⏰";
}
else if (normalized.Contains("urlaub") || normalized == "urlaub" || normalized.Contains("maximizer") || normalized.Contains("brückentag"))
{
responseText = $"Der **Urlaubs-Maximizer** analysiert das Kalenderjahr, gesetzliche Feiertage und Brückentage, um dir die besten Zeiträume für Urlaub vorzuschlagen.\n\nDu findest die Auswertung direkt in der Sidebar unter **'Urlaubs-Maximizer'**. So machst du aus wenigen Urlaubstagen maximale Freizeit! 🏖️✈️";
}
else if (normalized.Contains("hilfe") || normalized == "hilfe" || normalized.Contains("hallo") || normalized.Contains("hi") || normalized.Contains("helo") || normalized.Contains("huhu") || normalized == "hilfe")
{
responseText = $"Ich kann dir Auskunft über deine Zeiten geben. Frage mich zum Beispiel:\n\n" +
$"• *'Wie viele Überstunden habe ich?'*\n" +
$"• *'Wie sieht meine Woche aus?'*\n" +
$"• *'Was macht der Urlaubs-Maximizer?'*\n\n" +
$"Oder tippe einfach deine Frage ein! 💡";
}
else
{
responseText = $"Entschuldige, diese Frage kann ich leider noch nicht beantworten. 🤖\n\nFrage mich gerne nach deinem **Überstunden-Saldo**, deiner **Wochen-Arbeitszeit** oder dem **Urlaubs-Maximizer**!";
}
_messages.Add(new ChatMessage { Sender = "Bot", Content = responseText, Timestamp = DateTime.Now });
await ScrollToBottom();
}
private async Task ScrollToBottom()
{
try
{
await JS.InvokeVoidAsync("eval", "setTimeout(() => { const el = document.getElementById('timebot-body-el'); if(el) el.scrollTop = el.scrollHeight; }, 50);");
}
catch
{
// Prerender-Pass ignorieren
}
}
private string FormatMessageText(string text)
{
// Simple helper to replace markdown bold (**text**) with HTML bold (<strong>text</strong>)
// and newlines (\n) with <br />
if (string.IsNullOrEmpty(text)) return "";
var formatted = text;
// Escape HTML first to prevent XSS
formatted = System.Net.WebUtility.HtmlEncode(formatted);
// Replace back escaped ** formatting
// Simple regex replace for **text**
var regex = new System.Text.RegularExpressions.Regex(@"\*\*(.*?)\*\*");
formatted = regex.Replace(formatted, "<strong>$1</strong>");
// Replace newlines
formatted = formatted.Replace("\n", "<br />");
return formatted;
}
private static DateOnly GetMonday(DateOnly date)
{
int diff = ((int)date.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7;
return date.AddDays(-diff);
}
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}";
}
private sealed class ChatMessage
{
public string Sender { get; set; } = "Bot";
public string Content { get; set; } = "";
public DateTime Timestamp { get; set; } = DateTime.Now;
public bool IsTyping { get; set; } = false;
}
}
@@ -1,334 +0,0 @@
/* Floating Launcher Button */
.timebot-launcher {
position: fixed;
bottom: 24px;
right: 24px;
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, #ffffff 0%, #f1f3f9 100%);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
cursor: pointer;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
border: 2px solid #0EA5E9;
}
.timebot-launcher:hover {
transform: scale(1.1) translateY(-4px);
box-shadow: 0 12px 28px rgba(14, 165, 233, 0.4);
border-color: #0284C7;
}
.timebot-launcher img {
width: 48px;
height: 48px;
object-fit: contain;
}
.timebot-launcher.open {
transform: scale(0.9) rotate(90deg);
opacity: 0.8;
}
.timebot-badge {
position: absolute;
top: -4px;
right: -4px;
background-color: #ff3b30;
color: white;
font-size: 11px;
font-weight: bold;
padding: 3px 7px;
border-radius: 10px;
border: 2px solid white;
}
/* Chat Window */
.timebot-chat-window {
position: fixed;
bottom: 100px;
right: 24px;
width: 360px;
height: 520px;
max-height: 80vh;
border-radius: 20px;
background: rgba(32, 33, 36, 0.95);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 9999;
animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
color: #f1f3f9;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Header */
.timebot-header {
background: #0F172A;
padding: 14px 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.timebot-header-info {
display: flex;
align-items: center;
gap: 12px;
}
.timebot-header-avatar {
width: 36px;
height: 36px;
background: rgba(255, 255, 255, 0.15);
border-radius: 50%;
padding: 2px;
object-fit: contain;
}
.timebot-header-text {
display: flex;
flex-direction: column;
}
.timebot-header-title {
font-weight: 700;
font-size: 15px;
color: white;
letter-spacing: 0.3px;
}
.timebot-header-status {
font-size: 11px;
color: rgba(255, 255, 255, 0.75);
display: flex;
align-items: center;
gap: 4px;
}
.status-dot {
width: 6px;
height: 6px;
background-color: #4cd964;
border-radius: 50%;
display: inline-block;
box-shadow: 0 0 8px #4cd964;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(76, 217, 100, 0.7); }
70% { transform: scale(1); box-shadow: 0 0 0 6px rgba(76, 217, 100, 0); }
100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(76, 217, 100, 0); }
}
.timebot-close-btn {
background: transparent;
border: none;
cursor: pointer;
opacity: 0.8;
transition: opacity 0.2s;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.timebot-close-btn:hover {
opacity: 1;
}
/* Chat Body */
.timebot-body {
flex: 1;
padding: 16px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
background: rgba(24, 25, 28, 0.4);
}
.timebot-msg-row {
display: flex;
gap: 8px;
max-width: 85%;
}
.timebot-msg-row.bot-row {
align-self: flex-start;
}
.timebot-msg-row.user-row {
align-self: flex-end;
flex-direction: row-reverse;
}
.timebot-msg-avatar {
width: 28px;
height: 28px;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
padding: 2px;
align-self: flex-end;
object-fit: contain;
}
.timebot-msg-bubble {
padding: 10px 14px;
border-radius: 16px;
font-size: 13.5px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
.bot-bubble {
background: rgba(255, 255, 255, 0.08);
color: #e3e3e7;
border-bottom-left-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.04);
}
.user-bubble {
background: linear-gradient(135deg, #0EA5E9 0%, #0284C7 100%);
color: white;
border-bottom-right-radius: 4px;
box-shadow: 0 4px 12px rgba(14, 165, 233, 0.2);
}
/* Typing Indicator */
.typing-indicator {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 0;
}
.typing-indicator span {
width: 6px;
height: 6px;
background-color: #a8b2c1;
border-radius: 50%;
display: inline-block;
animation: bounce 1.4s infinite both;
}
.typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
.typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1.0); }
}
/* Suggestions */
.timebot-suggestions {
display: flex;
gap: 6px;
padding: 8px 12px;
background: rgba(18, 18, 20, 0.6);
border-top: 1px solid rgba(255, 255, 255, 0.06);
overflow-x: auto;
scrollbar-width: none; /* Scrollbar hidden for visual aesthetics */
}
.timebot-suggestions::-webkit-scrollbar {
display: none;
}
.timebot-suggestions button {
flex-shrink: 0;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
color: #cbd5e1;
padding: 6px 12px;
border-radius: 14px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
}
.timebot-suggestions button:hover {
background: rgba(14, 165, 233, 0.15);
border-color: #0EA5E9;
color: white;
transform: translateY(-1px);
}
/* Footer */
.timebot-footer {
padding: 12px;
display: flex;
align-items: center;
gap: 8px;
background: rgba(32, 33, 36, 0.98);
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.timebot-input {
flex: 1;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
color: white;
padding: 10px 14px;
border-radius: 12px;
font-size: 13px;
outline: none;
transition: all 0.2s;
}
.timebot-input:focus {
border-color: #0EA5E9;
background: rgba(255, 255, 255, 0.1);
box-shadow: 0 0 0 2px rgba(14, 165, 233, 0.2);
}
.timebot-send-btn {
background: white;
border: none;
border-radius: 10px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.timebot-send-btn:hover {
background: #e0f2fe;
transform: scale(1.05);
}
.timebot-send-btn:disabled {
background: rgba(255, 255, 255, 0.04);
cursor: not-allowed;
transform: none;
}
.timebot-send-btn:disabled ::deep .mud-icon-root {
color: rgba(255, 255, 255, 0.2) !important;
}
@@ -1,224 +0,0 @@
@inherits LayoutComponentBase
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Nav
@inject IUserNotificationService UserNotificationService
@inject IJSRuntime JSRuntime
@inject HttpClient Http
@implements IDisposable
@using System.Security.Claims
<MudThemeProvider Theme="_theme" @bind-IsDarkMode="_isDarkMode" />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
<MudLayout>
<MudAppBar Elevation="0" Style="background: color-mix(in srgb, var(--mud-palette-appbar-background) 85%, transparent); border-bottom: 1px solid rgba(255, 255, 255, 0.08); color: var(--mud-palette-appbar-text);">
<AuthorizeView>
<Authorized>
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" OnClick="ToggleDrawer" Class="d-flex d-md-none mr-2" />
</Authorized>
</AuthorizeView>
<MudSpacer />
<MudIconButton Icon="@(_isDarkMode ? Icons.Material.Filled.LightMode : Icons.Material.Filled.DarkMode)"
Color="Color.Inherit"
OnClick="ToggleDarkMode"
Class="mr-2" />
</MudAppBar>
<AuthorizeView>
<Authorized>
<MudDrawer @bind-Open="_drawerOpen" ClipMode="DrawerClipMode.Never" Elevation="0" Variant="DrawerVariant.Mini" OpenMiniOnHover="false" Class="modern-drawer">
<NavMenu OnToggleDrawer="ToggleDrawer" IsCollapsed="!_drawerOpen" />
</MudDrawer>
</Authorized>
</AuthorizeView>
<MudMainContent>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4 pb-8">
@Body
</MudContainer>
</MudMainContent>
<AuthorizeView>
<Authorized>
<ChatbotWidget />
</Authorized>
</AuthorizeView>
</MudLayout>
@code {
[PersistentState(AllowUpdates = true)]
public TenantInfo? PersistentTenantInfo { get; set; }
private bool _drawerOpen = true;
private bool _isDarkMode;
protected override async Task OnInitializedAsync()
{
UserNotificationService.OnUserDeleted += HandleUserDeleted;
if (PersistentTenantInfo != null)
{
ApplyTheme(PersistentTenantInfo);
}
else
{
try
{
var tenantInfo = await Http.GetFromJsonAsync<TenantInfo>("api/tenant/current");
if (tenantInfo != null)
{
ApplyTheme(tenantInfo);
PersistentTenantInfo = tenantInfo;
}
}
catch
{
// Ignore background error
}
}
}
private void ApplyTheme(TenantInfo tenantInfo)
{
if (tenantInfo.IsTenant && !string.IsNullOrEmpty(tenantInfo.PrimaryColor))
{
var primaryColor = tenantInfo.PrimaryColor;
var secondaryColor = tenantInfo.SecondaryColor ?? tenantInfo.PrimaryColor;
_theme.PaletteLight.Primary = primaryColor;
_theme.PaletteLight.Secondary = secondaryColor;
_theme.PaletteDark.Primary = primaryColor;
_theme.PaletteDark.Secondary = secondaryColor;
// Dynamically apply background colors to navbar
_theme.PaletteLight.AppbarBackground = primaryColor;
_theme.PaletteLight.DrawerBackground = primaryColor;
_theme.PaletteDark.AppbarBackground = primaryColor;
_theme.PaletteDark.DrawerBackground = primaryColor;
// Adjust text/icon contrast based on background luminance
var isLight = IsLightColor(primaryColor);
var textCol = isLight ? "#0F172A" : "#FFFFFF";
var subTextCol = isLight ? "rgba(15, 23, 42, 0.70)" : "rgba(255, 255, 255, 0.70)";
_theme.PaletteLight.AppbarText = textCol;
_theme.PaletteLight.DrawerText = subTextCol;
_theme.PaletteLight.DrawerIcon = subTextCol;
_theme.PaletteLight.PrimaryContrastText = textCol;
_theme.PaletteDark.AppbarText = textCol;
_theme.PaletteDark.DrawerText = subTextCol;
_theme.PaletteDark.DrawerIcon = subTextCol;
_theme.PaletteDark.PrimaryContrastText = textCol;
}
}
private bool IsLightColor(string hexColor)
{
if (string.IsNullOrEmpty(hexColor)) return false;
hexColor = hexColor.TrimStart('#');
if (hexColor.Length == 3)
{
hexColor = new string(new[] { hexColor[0], hexColor[0], hexColor[1], hexColor[1], hexColor[2], hexColor[2] });
}
if (hexColor.Length != 6) return false;
try
{
var r = Convert.ToInt32(hexColor.Substring(0, 2), 16);
var g = Convert.ToInt32(hexColor.Substring(2, 2), 16);
var b = Convert.ToInt32(hexColor.Substring(4, 2), 16);
var luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255.0;
return luminance > 0.6; // slightly conservative threshold for white text readability
}
catch
{
return false;
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
var saved = await JSRuntime.InvokeAsync<string>("localStorage.getItem", "darkMode");
if (!string.IsNullOrEmpty(saved))
{
_isDarkMode = bool.Parse(saved);
StateHasChanged();
}
}
catch
{
// Ignored during prerendering/SSR
}
}
}
private async Task ToggleDarkMode()
{
_isDarkMode = !_isDarkMode;
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "darkMode", _isDarkMode.ToString().ToLower());
}
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 = "#1E293B",
PrimaryDarken = "#0F172A",
PrimaryLighten = "#475569",
Secondary = "#0EA5E9",
SecondaryDarken = "#0284C7",
AppbarBackground = "#0F172A",
Background = "#F8FAFC",
DrawerBackground = "#0F172A",
DrawerText = "#94A3B8",
DrawerIcon = "#94A3B8",
Surface = "#FFFFFF",
TextPrimary = "#0F172A",
TextSecondary = "#475569",
},
PaletteDark = new PaletteDark
{
Primary = "#0EA5E9",
PrimaryDarken = "#0284C7",
PrimaryLighten = "#38BDF8",
Secondary = "#0EA5E9",
SecondaryDarken = "#0284C7",
AppbarBackground = "#0F172A",
AppbarText = "#F8FAFC",
Background = "#0B0F19",
DrawerBackground = "#0F172A",
DrawerText = "#94A3B8",
DrawerIcon = "#94A3B8",
Surface = "#1E293B",
TextPrimary = "#F8FAFC",
TextSecondary = "#94A3B8",
ActionDefault = "#94A3B8",
Divider = "rgba(255, 255, 255, 0.08)",
}
};
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
}
@@ -1,57 +0,0 @@
/* Custom scrollbar for the modern drawer */
::deep .modern-drawer .mud-drawer-content::-webkit-scrollbar {
width: 6px;
}
::deep .modern-drawer .mud-drawer-content::-webkit-scrollbar-track {
background: transparent;
}
::deep .modern-drawer .mud-drawer-content::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
::deep .modern-drawer .mud-drawer-content::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
::deep .modern-drawer {
background-color: var(--mud-palette-drawer-background) !important;
border-right: 1px solid rgba(255, 255, 255, 0.08) !important;
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.1) !important;
transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1) !important;
}
/* Glassmorphism for the appbar */
::deep .mud-appbar {
background: color-mix(in srgb, var(--mud-palette-appbar-background) 85%, transparent) !important;
backdrop-filter: blur(12px) !important;
-webkit-backdrop-filter: blur(12px) !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
}
/* Smooth transition in main layout content */
::deep .mud-main-content {
transition: padding-left 0.25s cubic-bezier(0.4, 0, 0.2, 1) !important;
background-color: #F8FAFC;
}
/* Blazor Error UI */
#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;
}
@@ -1,185 +0,0 @@
@inject IJSRuntime JSRuntime
@inject HttpClient Http
<div class="nav-container onboarding-nav-menu @(IsCollapsed ? "collapsed" : "")">
@* --- Brand / Logo Section --- *@
<div class="nav-header">
<div class="logo-wrapper">
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" OnClick="OnToggleDrawer" Class="drawer-toggle-btn mr-1 d-none d-md-flex" />
<MudIcon Icon="@Icons.Material.Filled.AccessTime" Class="logo-icon" />
<span class="brand-text">Timetracker</span>
</div>
</div>
@* --- Navigation Links --- *@
<div class="nav-content">
<MudNavMenu Class="custom-nav-menu">
<MudTooltip Text="Dashboard" Placement="Placement.Right">
<MudNavLink Href="" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard" Class="custom-nav-link">Dashboard</MudNavLink>
</MudTooltip>
<MudTooltip Text="Wochenübersicht" Placement="Placement.Right">
<MudNavLink Href="week" Icon="@Icons.Material.Filled.CalendarMonth" Class="custom-nav-link">Wochenübersicht</MudNavLink>
</MudTooltip>
<MudTooltip Text="Monatsübersicht" Placement="Placement.Right">
<MudNavLink Href="month" Icon="@Icons.Material.Filled.CalendarViewMonth" Class="custom-nav-link">Monatsübersicht</MudNavLink>
</MudTooltip>
<MudTooltip Text="Statistiken" Placement="Placement.Right">
<MudNavLink Href="stats" Icon="@Icons.Material.Filled.BarChart" Class="custom-nav-link">Statistiken</MudNavLink>
</MudTooltip>
<MudTooltip Text="Feiertage" Placement="Placement.Right">
<MudNavLink Href="feiertage" Icon="@Icons.Material.Filled.Celebration" Class="custom-nav-link">Feiertage</MudNavLink>
</MudTooltip>
<MudTooltip Text="Urlaubs-Maximizer" Placement="Placement.Right">
<MudNavLink Href="urlaub-maximizer" Icon="@Icons.Material.Filled.AutoAwesome" Class="custom-nav-link">Urlaubs-Maximizer</MudNavLink>
</MudTooltip>
<MudTooltip Text="Einstellungen" Placement="Placement.Right">
<MudNavLink Href="settings" Icon="@Icons.Material.Filled.Settings" Class="custom-nav-link">Einstellungen</MudNavLink>
</MudTooltip>
<AuthorizeView Policy="AdminOnly">
<Authorized>
<div class="admin-divider-container">
<span class="admin-divider-text">Admin</span>
<hr class="admin-divider-line" />
</div>
<MudTooltip Text="Firmen verwalten (SaaS)" Placement="Placement.Right">
<MudNavLink Href="superadmin" Icon="@Icons.Material.Filled.Business" Class="custom-nav-link admin-link">
Firmen verwalten
</MudNavLink>
</MudTooltip>
<MudTooltip Text="Benutzerverwaltung" Placement="Placement.Right">
<MudNavLink Href="admin/users" Icon="@Icons.Material.Filled.AdminPanelSettings" Class="custom-nav-link admin-link">
Benutzerverwaltung
</MudNavLink>
</MudTooltip>
</Authorized>
</AuthorizeView>
</MudNavMenu>
</div>
@* --- Footer (User Profile & Action Info) --- *@
<div class="nav-footer">
<AuthorizeView>
<Authorized>
<NavLink href="/profile" class="user-profile-link">
<div class="user-profile-card">
<div class="avatar-container">
<MudAvatar Class="user-avatar">
@GetInitials(context.User.Identity?.Name)
</MudAvatar>
<span class="status-dot"></span>
</div>
<div class="user-info">
<span class="user-name">@context.User.Identity?.Name</span>
<span class="user-status">@GetUserStatus(context.User.Identity?.Name)</span>
</div>
</div>
</NavLink>
</Authorized>
</AuthorizeView>
<MudNavMenu Class="footer-nav-menu">
<AuthorizeView>
<Authorized>
<MudTooltip Text="Abmelden" Placement="Placement.Right">
<MudNavLink Href="/auth/logout" Icon="@Icons.Material.Filled.Logout" Class="custom-nav-link logout-link">Abmelden</MudNavLink>
</MudTooltip>
</Authorized>
</AuthorizeView>
<MudTooltip Text="Changelog anzeigen" Placement="Placement.Right">
<MudNavLink Href="/changelog" Icon="@Icons.Material.Filled.NewReleases" Class="custom-nav-link version-link" OnClick="ClearBadge">
<span class="version-text">Version @timetracker.Client.Components.Pages.Changelog.LatestVersion</span>
@if (_showVersionBadge)
{
<span class="version-badge">1</span>
}
</MudNavLink>
</MudTooltip>
</MudNavMenu>
</div>
</div>
@code {
[PersistentState(AllowUpdates = true)]
public string? PersistentCompanyName { get; set; }
[Parameter] public EventCallback OnToggleDrawer { get; set; }
[Parameter] public bool IsCollapsed { get; set; }
private bool _showVersionBadge;
private string _companyName = "Hauptdomain";
protected override async Task OnInitializedAsync()
{
if (PersistentCompanyName != null)
{
_companyName = PersistentCompanyName;
}
else
{
try
{
var tenantInfo = await Http.GetFromJsonAsync<TenantInfo>("api/tenant/current");
if (tenantInfo?.IsTenant == true && !string.IsNullOrEmpty(tenantInfo.Name))
{
_companyName = tenantInfo.Name;
PersistentCompanyName = _companyName;
}
}
catch
{
// Ignore background error
}
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
var lastSeen = await JSRuntime.InvokeAsync<string>("localStorage.getItem", "lastSeenVersion");
var latest = timetracker.Client.Components.Pages.Changelog.LatestVersion;
var isHighlighted = timetracker.Client.Components.Pages.Changelog.IsLatestHighlighted;
_showVersionBadge = isHighlighted && lastSeen != latest;
StateHasChanged();
}
catch
{
// Ignored in SSR
}
}
}
private async Task ClearBadge()
{
_showVersionBadge = false;
try
{
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "lastSeenVersion", timetracker.Client.Components.Pages.Changelog.LatestVersion);
}
catch
{
// Ignored
}
}
private string GetInitials(string? name)
{
if (string.IsNullOrWhiteSpace(name))
return "U";
var parts = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 1)
return parts[0][..Math.Min(2, parts[0].Length)].ToUpper();
return $"{parts[0][0]}{parts[1][0]}".ToUpper();
}
private string GetUserStatus(string? username)
{
if (string.IsNullOrWhiteSpace(username)) return "";
if (username.Equals("marc", StringComparison.OrdinalIgnoreCase)) return "SuperAdmin";
if (_companyName == "Hauptdomain") return "Einzelnutzer";
return _companyName;
}
}
@@ -1,403 +0,0 @@
/* Base container covering full height */
.nav-container {
display: flex;
flex-direction: column;
height: 100%;
color: #94A3B8; /* Slate 400 */
}
/* Ensure tooltips wrapper spans full width of menu */
::deep .mud-tooltip-root {
display: block !important;
width: 100% !important;
}
/* Header with logo and title */
.nav-header {
height: 64px;
display: flex;
align-items: center;
padding: 0 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
margin-bottom: 8px;
box-sizing: border-box;
}
.logo-wrapper {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
transition: all 0.25s ease;
}
::deep .drawer-toggle-btn {
color: #94A3B8 !important;
padding: 8px !important;
margin-left: -8px !important;
transition: all 0.2s ease !important;
}
::deep .drawer-toggle-btn:hover {
color: #FFFFFF !important;
background-color: rgba(255, 255, 255, 0.05) !important;
}
::deep .logo-icon {
color: var(--mud-palette-secondary) !important;
font-size: 1.6rem !important;
transition: opacity 0.2s ease;
}
.brand-text {
font-size: 1.1rem;
font-weight: 800;
color: var(--mud-palette-appbar-text);
letter-spacing: 0.5px;
white-space: nowrap;
opacity: 1;
transition: opacity 0.2s ease;
}
/* Navigation scrollable content */
.nav-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
/* Divider inside navigation list */
.admin-divider-container {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 20px 8px 20px;
box-sizing: border-box;
transition: padding 0.25s ease;
}
.admin-divider-text {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1.5px;
color: #475569; /* Slate 600 */
white-space: nowrap;
}
.admin-divider-line {
flex: 1;
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.06);
margin: 0;
}
/* Standard NavLink customization */
::deep .custom-nav-link {
margin: 4px 12px !important;
border-radius: 12px !important;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important;
overflow: hidden;
}
/* Normal (inactive) text and icon colors */
::deep .custom-nav-link,
::deep .custom-nav-link .mud-nav-link-text,
::deep .custom-nav-link .mud-nav-link-icon-default {
color: var(--mud-palette-drawer-text) !important;
}
/* NavLink hover */
::deep .custom-nav-link:hover {
background-color: rgba(255, 255, 255, 0.05) !important;
}
::deep .custom-nav-link:hover,
::deep .custom-nav-link:hover .mud-nav-link-text,
::deep .custom-nav-link:hover .mud-nav-link-icon-default {
color: var(--mud-palette-appbar-text) !important;
}
/* NavLink active state (Circle around icon) */
::deep .custom-nav-link.active,
::deep .custom-nav-link .active {
background: transparent !important;
font-weight: 600 !important;
box-shadow: none !important;
}
::deep .custom-nav-link.active .mud-nav-link-text,
::deep .custom-nav-link .active .mud-nav-link-text {
color: var(--mud-palette-secondary) !important;
}
::deep .custom-nav-link.active .mud-nav-link-icon-default,
::deep .custom-nav-link .active .mud-nav-link-icon-default {
color: var(--mud-palette-secondary) !important;
background-color: color-mix(in srgb, var(--mud-palette-secondary) 15%, transparent) !important;
border-radius: 50% !important;
padding: 6px !important;
margin: -6px 8px -6px -6px !important;
width: 36px !important;
height: 36px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
/* Admin link active state (Circle around icon) */
::deep .admin-link .mud-nav-link-icon-default {
color: rgba(239, 68, 68, 0.7) !important; /* Red 500 */
}
::deep .admin-link.active,
::deep .admin-link .active {
background: transparent !important;
box-shadow: none !important;
}
::deep .admin-link.active .mud-nav-link-text,
::deep .admin-link .active .mud-nav-link-text {
color: #EF4444 !important;
}
::deep .admin-link.active .mud-nav-link-icon-default,
::deep .admin-link .active .mud-nav-link-icon-default {
color: #EF4444 !important;
background-color: rgba(239, 68, 68, 0.15) !important;
border-radius: 50% !important;
padding: 6px !important;
margin: -6px 8px -6px -6px !important;
width: 36px !important;
height: 36px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
/* Version link active state */
::deep .version-link.active,
::deep .version-link .active {
opacity: 1 !important;
}
::deep .version-link.active .version-text,
::deep .version-link .active .version-text {
color: #0EA5E9 !important;
}
/* Footer Section */
.nav-footer {
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding: 12px 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
background: rgba(15, 23, 42, 0.2);
}
/* User profile link */
.user-profile-link {
text-decoration: none !important;
color: inherit !important;
display: block;
cursor: pointer;
}
.user-profile-link:hover .user-profile-card {
background-color: rgba(255, 255, 255, 0.05);
border-radius: 12px;
}
.user-profile-card {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 20px;
margin-bottom: 8px;
box-sizing: border-box;
transition: all 0.25s ease;
}
.avatar-container {
position: relative;
display: inline-flex;
}
::deep .user-avatar {
width: 36px !important;
height: 36px !important;
background: linear-gradient(135deg, var(--mud-palette-secondary) 0%, var(--mud-palette-secondary-darken) 100%) !important;
color: #FFFFFF !important;
font-weight: 700 !important;
font-size: 0.875rem !important;
border: 2px solid rgba(255, 255, 255, 0.1) !important;
}
.status-dot {
position: absolute;
bottom: -1px;
right: -1px;
width: 10px;
height: 10px;
background-color: #10B981; /* Emerald 500 */
border: 2px solid var(--mud-palette-drawer-background);
border-radius: 50%;
}
.user-info {
display: flex;
flex-direction: column;
overflow: hidden;
transition: opacity 0.15s ease;
}
.user-name {
font-size: 0.85rem;
font-weight: 600;
color: var(--mud-palette-appbar-text);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.user-status {
font-size: 0.7rem;
color: #10B981;
}
/* Special links in footer */
::deep .logout-link .mud-nav-link-icon-default {
color: rgba(148, 163, 184, 0.6) !important;
}
::deep .logout-link:hover .mud-nav-link-icon-default {
color: #EF4444 !important;
}
::deep .version-link {
opacity: 0.7;
}
::deep .version-link .mud-nav-link-text {
font-size: 0.75rem !important;
display: flex !important;
align-items: center;
gap: 8px;
}
::deep .version-badge {
background-color: #EF4444;
color: white;
font-size: 0.65rem;
font-weight: 700;
min-width: 16px;
height: 16px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
box-shadow: 0 0 4px rgba(239, 68, 68, 0.4);
animation: pulse-badge 2s infinite;
}
@keyframes pulse-badge {
0% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7);
}
70% {
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0);
}
}
/* --- COLLAPSED DRAWER (MINI STATE) STYLES --- */
/* Smooth transitions for all elements that change */
::deep .logo-icon, .brand-text, .admin-divider-container, .admin-divider-text, .admin-divider-line, .user-profile-card, .user-info, .version-text, ::deep .custom-nav-link .mud-nav-link-text {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.collapsed ::deep .logo-icon,
.collapsed .brand-text {
opacity: 0;
width: 0;
max-width: 0;
overflow: hidden;
margin: 0;
padding: 0;
transform: scale(0);
}
.collapsed .logo-wrapper {
justify-content: center;
gap: 0;
}
.collapsed ::deep .drawer-toggle-btn {
margin-left: 0 !important;
}
.collapsed .admin-divider-container {
padding: 16px 0 8px 0;
justify-content: center;
}
.collapsed .admin-divider-text {
opacity: 0;
width: 0;
overflow: hidden;
margin: 0;
}
.collapsed .admin-divider-line {
width: 24px;
flex: none;
}
.collapsed .user-profile-card {
justify-content: center;
padding: 8px 0;
gap: 0;
}
.collapsed .user-info {
opacity: 0;
width: 0;
overflow: hidden;
margin: 0;
}
.collapsed .version-text {
opacity: 0;
width: 0;
overflow: hidden;
margin: 0;
}
/* Hide link text but keep smooth transition by manipulating width/opacity if possible, but MudBlazor's DOM might require display none after transition. We'll use opacity/width/overflow. */
.collapsed ::deep .custom-nav-link .mud-nav-link-text {
opacity: 0;
width: 0;
overflow: hidden;
margin: 0 !important;
white-space: nowrap;
}
/* Center link icons when mini and adjust spacing */
.collapsed ::deep .custom-nav-link {
margin: 4px auto !important;
padding: 8px 0 !important;
justify-content: center !important;
width: 44px !important;
min-height: 44px !important;
display: flex !important;
align-items: center !important;
border-radius: 12px !important;
}
.collapsed ::deep .custom-nav-link .mud-nav-link-icon-default {
margin-right: 0 !important;
}
@@ -1,61 +0,0 @@
@using System.Globalization
@using MudBlazor
<MudDialog>
<DialogContent>
<MudStack Spacing="3" Class="pa-2">
<MudSelect @bind-Value="_selectedYear" Label="Jahr" Variant="Variant.Outlined">
@foreach (var year in _years)
{
<MudSelectItem Value="@year">@year</MudSelectItem>
}
</MudSelect>
<MudSelect @bind-Value="_selectedMonth" Label="Monat" Variant="Variant.Outlined">
@foreach (var month in _months)
{
<MudSelectItem Value="@month.Value">@month.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Abbrechen</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Submit">Herunterladen</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] IMudDialogInstance MudDialog { get; set; } = null!;
private int _selectedYear = DateTime.Today.Year;
private int _selectedMonth = DateTime.Today.Month;
private List<int> _years = Enumerable.Range(DateTime.Today.Year - 5, 6).OrderByDescending(y => y).ToList();
private class MonthOption
{
public int Value { get; set; }
public string Name { get; set; } = "";
}
private List<MonthOption> _months = [];
protected override void OnInitialized()
{
var deCulture = new CultureInfo("de-DE");
_months = Enumerable.Range(1, 12).Select(m => new MonthOption
{
Value = m,
Name = deCulture.DateTimeFormat.GetMonthName(m)
}).ToList();
}
public class MonthSelectorResult
{
public int Year { get; set; }
public int Month { get; set; }
}
void Submit() => MudDialog.Close(DialogResult.Ok(new MonthSelectorResult { Year = _selectedYear, Month = _selectedMonth }));
void Cancel() => MudDialog.Cancel();
}
@@ -1,139 +0,0 @@
@page "/agb"
@rendermode InteractiveWebAssembly
@attribute [AllowAnonymous]
<PageTitle>AGB Timetracker</PageTitle>
<MudContainer MaxWidth="MaxWidth.Medium" Class="mt-8 pb-8">
<MudStack Spacing="4">
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudIcon Icon="@Icons.Material.Filled.Gavel" Style="color: var(--mud-palette-primary-text); font-size: 2.2rem;" />
<MudStack Spacing="0">
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight: 700;">Allgemeine Geschäftsbedingungen (AGB)</MudText>
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72;">
Rechtlich absolut unverbindliches Kauderwelsch und Flachwitze
</MudText>
</MudStack>
</MudStack>
</MudPaper>
@* ── Paragraphs with Flachwitze ── *@
<MudCard Elevation="2" Class="rounded-xl">
<MudCardContent>
<MudText Typo="Typo.h6" Style="font-weight: 700; color: var(--mud-palette-primary);" Class="mb-2">
§ 1 Geltungsbereich & Transparenz
</MudText>
<MudText Typo="Typo.body2" Class="mb-3">
Diese Bedingungen gelten für die gesamte Nutzung dieses Zeiterfassungs-Tools. Transparenz ist uns extrem wichtig.
</MudText>
<MudAlert Severity="Severity.Info" Dense="true" Class="rounded-lg">
<b>Warum können Geister eigentlich so schlecht lügen?</b><br />
Weil sie einfach extrem leicht zu durchschauen sind!
</MudAlert>
</MudCardContent>
</MudCard>
<MudCard Elevation="2" Class="rounded-xl">
<MudCardContent>
<MudText Typo="Typo.h6" Style="font-weight: 700; color: var(--mud-palette-primary);" Class="mb-2">
§ 2 Pflichten des Nutzers & Gärtnerklauseln
</MudText>
<MudText Typo="Typo.body2" Class="mb-3">
Jeder Nutzer verpflichtet sich, seine Arbeitszeiten wahrheitsgemäß und pünktlich einzutragen. Bei Zuwiderhandlung behalten wir uns vor, den Garten des Nutzers aufzuräumen.
</MudText>
<MudAlert Severity="Severity.Normal" Dense="true" Class="rounded-lg" Style="border-left: 4px solid #EF6C00; background: rgba(239, 108, 0, 0.05);">
<b>Was sagt ein Gärtner, wenn er seinen Arbeitsplatz vorzeitig verlässt?</b><br />
"Ich mach mich dann mal vom Acker!"
</MudAlert>
</MudCardContent>
</MudCard>
<MudCard Elevation="2" Class="rounded-xl">
<MudCardContent>
<MudText Typo="Typo.h6" Style="font-weight: 700; color: var(--mud-palette-primary);" Class="mb-2">
§ 3 Haftungsausschluss & Straßenverkehr
</MudText>
<MudText Typo="Typo.body2" Class="mb-3">
Wir übernehmen keinerlei Haftung für vergessene Pausenzeiten, verpasste Feierabende oder schlechtes Wetter.
</MudText>
<MudAlert Severity="Severity.Warning" Dense="true" Class="rounded-lg">
<b>Was ist rot, rund und steht am Straßenrand?</b><br />
Eine Spargelampel!<br /><br />
<b>Und warum dürfen Bienen eigentlich nicht in die Kirche?</b><br />
Weil sie in der Sekte sind!
</MudAlert>
</MudCardContent>
</MudCard>
<MudCard Elevation="2" Class="rounded-xl">
<MudCardContent>
<MudText Typo="Typo.h6" Style="font-weight: 700; color: var(--mud-palette-primary);" Class="mb-2">
§ 4 Datensicherheit & Physikalische Grundgesetze
</MudText>
<MudText Typo="Typo.body2" Class="mb-3">
Ihre Daten werden nach modernsten Verschlüsselungsstandards (nicht) an Dritte weitergegeben. Unsere Magnetbänder sind streng gesichert.
</MudText>
<MudAlert Severity="Severity.Normal" Dense="true" Class="rounded-lg" Style="border-left: 4px solid #00897B; background: rgba(0, 137, 123, 0.05);">
<b>Treffen sich zwei Magnete im Rechenzentrum. Sagt der eine ganz verzweifelt:</b><br />
"Du, sag mal... Was soll ich heute bloß anziehen?"
</MudAlert>
</MudCardContent>
</MudCard>
<MudCard Elevation="2" Class="rounded-xl">
<MudCardContent>
<MudText Typo="Typo.h6" Style="font-weight: 700; color: var(--mud-palette-primary);" Class="mb-2">
§ 5 Rückgaberecht & Büroalltag
</MudText>
<MudText Typo="Typo.body2" Class="mb-3">
Erfasste Arbeitszeiten können nicht zurückgenommen, umgetauscht oder bar ausgezahlt werden.
</MudText>
<MudAlert Severity="Severity.Normal" Dense="true" Class="rounded-lg" Style="border-left: 4px solid #5E35B1; background: rgba(94, 53, 177, 0.05);">
<b>Was macht ein gelangweilter Clown im Büro?</b><br />
Er macht Faxen!<br /><br />
<b>Und warum steht ein einsamer Pilz mitten im tiefen Wald?</b><br />
Weil die Tannen zapfen!
</MudAlert>
</MudCardContent>
</MudCard>
<MudCard Elevation="2" Class="rounded-xl">
<MudCardContent>
<MudText Typo="Typo.h6" Style="font-weight: 700; color: var(--mud-palette-primary);" Class="mb-2">
§ 6 Schlussbestimmungen & Salatgesetze
</MudText>
<MudText Typo="Typo.body2" Class="mb-3">
Sollten einzelne Bestimmungen dieser AGB unwirksam sein, bleibt die Fröhlichkeit aller anderen Bestimmungen unberührt.
</MudText>
<MudAlert Severity="Severity.Success" Dense="true" Class="rounded-lg">
<b>Wie nennt man ein plötzlich spurlos verschwundenes Stück Rindfleisch?</b><br />
Gulasch!<br /><br />
<b>Was ist grün, gesund und klopft nachts an die Haustür?</b><br />
Ein Klopfsalat!<br /><br />
<b>Und zu guter Letzt: Warum fliegen Vögel im Winter eigentlich nach Süden?</b><br />
Weil es zu Fuß einfach viel zu weit wäre!
</MudAlert>
</MudCardContent>
</MudCard>
@* ── Back Button ── *@
<MudStack Row="true" Justify="Justify.Center" Class="mt-4">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.ArrowBack" OnClick="GoBack" Style="border-radius: 20px;">
Zurück zur Anmeldung
</MudButton>
</MudStack>
</MudStack>
</MudContainer>
@code {
[Inject] private NavigationManager Nav { get; set; } = default!;
private void GoBack()
{
Nav.NavigateTo("/login");
}
}
@@ -1,230 +0,0 @@
@page "/"
@rendermode InteractiveWebAssembly
@attribute [Authorize]
@inject ITimetrackerService TrackerService
@inject AuthenticationStateProvider AuthStateProvider
@inject IJSRuntime JSRuntime
<PageTitle>Dashboard 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 Dashboard…</MudText>
</MudStack>
}
else
{
<MudStack Spacing="4">
@* ── Header & Einstellungen ── *@
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudMenu Icon="@Icons.Material.Filled.Settings" AnchorOrigin="Origin.BottomRight" TransformOrigin="Origin.TopRight" Dense="true" Color="Color.Secondary">
<MudMenuItem OnClick="@(() => ToggleWidget("EarlyBird"))">
<MudCheckBox T="bool" Value="@_showEarlyBird" Label="Early Bird anzeigen" Color="Color.Primary" ReadOnly="true"/>
</MudMenuItem>
<MudMenuItem OnClick="@(() => ToggleWidget("NightOwl"))">
<MudCheckBox T="bool" Value="@_showNightOwl" Label="Night Owl anzeigen" Color="Color.Primary" ReadOnly="true"/>
</MudMenuItem>
<MudMenuItem OnClick="@(() => ToggleWidget("Pausenkonig"))">
<MudCheckBox T="bool" Value="@_showPausenkonig" Label="Pausenkönig anzeigen" Color="Color.Primary" ReadOnly="true"/>
</MudMenuItem>
<MudMenuItem OnClick="@(() => ToggleWidget("Streaks"))">
<MudCheckBox T="bool" Value="@_showStreaks" Label="Streaks anzeigen" Color="Color.Primary" ReadOnly="true"/>
</MudMenuItem>
<MudDivider />
<MudMenuItem OnClick="@(() => ToggleWidget("Charts"))">
<MudCheckBox T="bool" Value="@_showCharts" Label="Diagramme anzeigen" Color="Color.Primary" ReadOnly="true"/>
</MudMenuItem>
</MudMenu>
</MudStack>
@* ── Gamification Widgets ── *@
<MudGrid>
@if (_showEarlyBird)
{
<MudItem xs="12" sm="6" md="3">
<MudPaper Elevation="2" Class="pa-4 rounded-xl" Style="background: linear-gradient(135deg, #fdfbfb 0%, #ebedee 100%);">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudAvatar Color="Color.Warning" Variant="Variant.Filled" Size="Size.Large">🌅</MudAvatar>
<MudStack Spacing="0">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Early Bird</MudText>
<MudText Typo="Typo.h5" Style="font-weight: 700">@FormatTimeSpan(_stats.EarliestStart)</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Frühester Beginn im Monat</MudText>
</MudStack>
</MudStack>
</MudPaper>
</MudItem>
}
@if (_showNightOwl)
{
<MudItem xs="12" sm="6" md="3">
<MudPaper Elevation="2" Class="pa-4 rounded-xl" Style="background: linear-gradient(135deg, #304352 0%, #d7d2cc 100%); color: white;">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudAvatar Color="Color.Dark" Variant="Variant.Filled" Size="Size.Large">🦉</MudAvatar>
<MudStack Spacing="0">
<MudText Typo="Typo.subtitle2" Style="opacity: 0.8">Night Owl</MudText>
<MudText Typo="Typo.h5" Style="font-weight: 700">@FormatTimeSpan(_stats.LatestEnd)</MudText>
<MudText Typo="Typo.caption" Style="opacity: 0.8">Spätestes Ende im Monat</MudText>
</MudStack>
</MudStack>
</MudPaper>
</MudItem>
}
@if (_showPausenkonig)
{
<MudItem xs="12" sm="6" md="3">
<MudPaper Elevation="2" Class="pa-4 rounded-xl" Style="background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudAvatar Color="Color.Info" Variant="Variant.Filled" Size="Size.Large">☕</MudAvatar>
<MudStack Spacing="0">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Pausenkönig</MudText>
<MudText Typo="Typo.h5" Style="font-weight: 700">@((int)_stats.AverageBreak.TotalMinutes) Min.</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Ø Pause im Monat</MudText>
</MudStack>
</MudStack>
</MudPaper>
</MudItem>
}
@if (_showStreaks)
{
<MudItem xs="12" sm="6" md="3">
<MudPaper Elevation="2" Class="pa-4 rounded-xl" Style="background: linear-gradient(135deg, #f6d365 0%, #fda085 100%); color: white;">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudAvatar Color="Color.Error" Variant="Variant.Filled" Size="Size.Large">🔥</MudAvatar>
<MudStack Spacing="0">
<MudText Typo="Typo.subtitle2" Style="opacity: 0.9">Streak</MudText>
<MudText Typo="Typo.h5" Style="font-weight: 700">@_stats.CurrentStreak / @_stats.LongestStreak</MudText>
<MudText Typo="Typo.caption" Style="opacity: 0.9">Aktuell / Bester (Tage)</MudText>
</MudStack>
</MudStack>
</MudPaper>
</MudItem>
}
</MudGrid>
@* ── Diagramme ── *@
@if (_showCharts)
{
<MudGrid>
<MudItem xs="12" md="8">
<MudPaper Elevation="2" Class="pa-4 rounded-xl h-100">
<MudText Typo="Typo.h6" Class="mb-4">Gleitzeit-Verlauf @DateTime.Today.Year</MudText>
<MudChart T="double" ChartType="ChartType.Bar"
ChartSeries="@_barChartSeries"
XAxisLabels="@_barChartLabels"
Width="100%" Height="300px" />
</MudPaper>
</MudItem>
<MudItem xs="12" md="4">
<MudPaper Elevation="2" Class="pa-4 rounded-xl h-100">
<MudText Typo="Typo.h6" Class="mb-4">Jahres-Verteilung</MudText>
<MudChart T="double" ChartType="ChartType.Pie"
InputData="@_pieChartData"
InputLabels="@_pieChartLabels"
Width="100%" Height="300px" />
</MudPaper>
</MudItem>
</MudGrid>
}
</MudStack>
}
@code {
private bool _loading = true;
private DashboardStatsVm _stats = new();
private DashboardChartDataVm _chartData = new();
private bool _showEarlyBird = true;
private bool _showNightOwl = true;
private bool _showPausenkonig = true;
private bool _showStreaks = true;
private bool _showCharts = true;
private List<ChartSeries<double>> _barChartSeries = new();
private string[] _barChartLabels = Array.Empty<string>();
private double[] _pieChartData = Array.Empty<double>();
private string[] _pieChartLabels = Array.Empty<string>();
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var claim = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
if (claim == null) return;
int userId = int.Parse(claim.Value);
int year = DateTime.Today.Year;
int month = DateTime.Today.Month;
await LoadSettings();
_stats = await TrackerService.GetDashboardStatsAsync(userId, year, month);
_chartData = await TrackerService.GetDashboardChartDataAsync(userId, year);
// Prepare Bar Chart
_barChartSeries = new List<ChartSeries<double>>
{
new ChartSeries<double> { Name = "Überstunden", Data = _chartData.MonthlyOvertime.Select(m => m.OvertimeHours).ToArray() }
};
_barChartLabels = _chartData.MonthlyOvertime.Select(m => m.MonthName).ToArray();
// Prepare Pie Chart
_pieChartData = new double[] { _chartData.WorkDaysCount, _chartData.VacationDaysCount, _chartData.PublicHolidaysCount };
_pieChartLabels = new string[] { "Arbeit", "Urlaub", "Feiertage" };
_loading = false;
}
private async Task LoadSettings()
{
try
{
var eb = await JSRuntime.InvokeAsync<string>("localStorage.getItem", "widget_EarlyBird");
if (eb != null) _showEarlyBird = eb == "true";
var no = await JSRuntime.InvokeAsync<string>("localStorage.getItem", "widget_NightOwl");
if (no != null) _showNightOwl = no == "true";
var pk = await JSRuntime.InvokeAsync<string>("localStorage.getItem", "widget_Pausenkonig");
if (pk != null) _showPausenkonig = pk == "true";
var st = await JSRuntime.InvokeAsync<string>("localStorage.getItem", "widget_Streaks");
if (st != null) _showStreaks = st == "true";
var ch = await JSRuntime.InvokeAsync<string>("localStorage.getItem", "widget_Charts");
if (ch != null) _showCharts = ch == "true";
}
catch
{
// SSR Fallback
}
}
private async Task ToggleWidget(string widget)
{
bool newState = true;
if (widget == "EarlyBird") { _showEarlyBird = !_showEarlyBird; newState = _showEarlyBird; }
if (widget == "NightOwl") { _showNightOwl = !_showNightOwl; newState = _showNightOwl; }
if (widget == "Pausenkonig") { _showPausenkonig = !_showPausenkonig; newState = _showPausenkonig; }
if (widget == "Streaks") { _showStreaks = !_showStreaks; newState = _showStreaks; }
if (widget == "Charts") { _showCharts = !_showCharts; newState = _showCharts; }
try
{
await JSRuntime.InvokeVoidAsync("localStorage.setItem", $"widget_{widget}", newState.ToString().ToLower());
}
catch { }
}
private string FormatTimeSpan(TimeSpan? ts)
{
if (!ts.HasValue) return "-";
return $"{ts.Value.Hours:D2}:{ts.Value.Minutes:D2}";
}
}
@@ -1,619 +0,0 @@
@page "/login"
@rendermode InteractiveWebAssembly
@attribute [AllowAnonymous]
@using System.Net.Http.Json
@inject IAuthService AuthService
@inject NavigationManager Nav
@inject IJSRuntime JSRuntime
@inject HttpClient Http
<PageTitle>@GetPageTitle()</PageTitle>
@if (TenantInfo == null)
{
@* Loading indicator while resolving Tenant *@
<MudContainer MaxWidth="MaxWidth.Small" Class="mt-16 text-center">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
<MudText Class="mt-4" Color="Color.Secondary">Lade Timetracker…</MudText>
</MudContainer>
}
else if (TenantInfo.IsTenant && !TenantInfo.IsApproved)
{
@* Pending Approval Screen *@
<MudContainer MaxWidth="MaxWidth.Small" Class="mt-16">
<MudStack AlignItems="AlignItems.Center" Spacing="4">
<MudStack AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.AccessTime" Style="font-size:4rem; color:#EAB308" />
<MudText Typo="Typo.h4" Style="font-weight:700; color:#EAB308">Timetracker</MudText>
</MudStack>
<MudPaper Elevation="4" Class="pa-8 rounded-xl text-center" Style="width:100%; border-top: 4px solid var(--mud-palette-warning);">
<MudIcon Icon="@Icons.Material.Filled.HourglassEmpty"
Style="font-size: 5rem; animation: pulse 2s infinite;"
Color="Color.Warning"
Class="mb-4" />
<MudText Typo="Typo.h5" Style="font-weight: 700;" Class="mb-2">Freischaltung ausstehend</MudText>
<MudText Typo="Typo.body1" Color="Color.Secondary" Class="mb-6">
Die Firma <strong>@TenantInfo.Name</strong> wurde erfolgreich registriert.
Der Zugang wird derzeit von unserem SuperAdmin geprüft und in Kürze freigeschaltet.
</MudText>
<MudDivider Class="my-4" />
<MudText Typo="Typo.caption" Color="Color.Secondary">
Bitte laden Sie diese Seite in Kürze neu, sobald Ihr Account freigegeben wurde.
</MudText>
</MudPaper>
</MudStack>
</MudContainer>
<style>
@@keyframes pulse {
0% { opacity: 0.6; transform: scale(1); }
50% { opacity: 1; transform: scale(1.05); }
100% { opacity: 0.6; transform: scale(1); }
}
</style>
}
else
{
@* Main login/registration container *@
<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:#0EA5E9" />
<MudText Typo="Typo.h4" Style="font-weight:700; color:#0EA5E9">
@(TenantInfo.IsTenant ? TenantInfo.Name : "Timetracker SaaS")
</MudText>
@if (!TenantInfo.IsTenant)
{
<MudText Typo="Typo.caption" Color="Color.Secondary">Zentrales Portal</MudText>
}
</MudStack>
<MudPaper Elevation="4" Class="pa-6 rounded-xl" Style="width:100%">
@if (_firmRegisteredSuccessfully)
{
@* Success feedback after firm registration *@
<MudStack Spacing="4" AlignItems="AlignItems.Center" Class="text-center pa-4">
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Style="font-size:5rem;" />
<MudText Typo="Typo.h5" Style="font-weight:700;">Registrierung erfolgreich!</MudText>
<MudText Typo="Typo.body1" Color="Color.Secondary">
Ihre Firma <strong>@_firmModel.CompanyName</strong> wurde erfolgreich registriert.
</MudText>
<MudAlert Severity="Severity.Info" Class="rounded-lg">
Ihr Zugang muss nun durch den SuperAdmin freigegeben werden. Danach können Sie sich unter Ihrer Subdomain anmelden:
<br /><br />
<strong>@GetTenantUrl()</strong>
</MudAlert>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="mt-4" OnClick="ResetRegistrationFlow">
Zurück zum Login
</MudButton>
</MudStack>
} else {
@* ── Header / Navigation ── *@
@if (_activeTab == 2)
{
@* ── Back Button for Firm Registration ── *@
<MudStack Row="true" AlignItems="AlignItems.Center" Class="mb-4">
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" OnClick="@(() => SetTab(0))" Color="Color.Primary" />
<MudText Typo="Typo.h6" Style="font-weight:700">Firma registrieren</MudText>
</MudStack>
<MudDivider Class="mb-6" />
}
else
{
@* ── 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 if (_activeTab == 1)
{
@if (TenantInfo.IsTenant)
{
@* ── Employee 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" />
<input type="text" style="display: none;" tabindex="-1" autocomplete="off" @bind="_honeypot" />
<MudCheckBox @bind-Value="_acceptAgb" Color="Color.Secondary" Class="mt-1">
<MudText Typo="Typo.body2">Ich akzeptiere die <a href="/agb" target="_blank" style="color: var(--mud-palette-secondary); text-decoration: underline; font-weight: 600;">AGB</a>.</MudText>
</MudCheckBox>
<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 || !_acceptAgb">
@if (_loading)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
}
Konto erstellen
</MudButton>
</MudStack>
</EditForm>
</MudStack>
}
else
{
@* ── Single User Register Form ── *@
<MudStack Spacing="3">
@if (_error != null)
{
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
}
<EditForm Model="@_registerModel" OnValidSubmit="HandleSingleUserRegister">
<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" />
<MudTextField T="string"
Label="Passwort wiederholen"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Lock"
InputType="InputType.Password"
@bind-Value="_confirmPassword"
Required="true" />
<input type="text" style="display: none;" tabindex="-1" autocomplete="off" @bind="_honeypot" />
<MudCheckBox @bind-Value="_acceptAgb" Color="Color.Secondary" Class="mt-1">
<MudText Typo="Typo.body2">Ich akzeptiere die <a href="/agb" target="_blank" style="color: var(--mud-palette-secondary); text-decoration: underline; font-weight: 600;">AGB</a>.</MudText>
</MudCheckBox>
<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 || !_acceptAgb">
@if (_loading)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
}
Konto erstellen
</MudButton>
</MudStack>
</EditForm>
</MudStack>
}
}
else if (_activeTab == 2)
{
@* ── Firm Register Form ── *@
<MudStack Spacing="3">
@if (_error != null)
{
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
}
<EditForm Model="@_firmModel" OnValidSubmit="HandleRegisterFirm">
<DataAnnotationsValidator />
<MudStack Spacing="3">
<MudTextField T="string"
Label="Firmenname (z.B. Acme GmbH)"
Variant="Variant.Outlined"
@bind-Value="_firmModel.CompanyName"
Required="true" />
<MudTextField T="string"
Label="Subdomain"
Variant="Variant.Outlined"
HelperText="Nur Kleinbuchstaben, Zahlen & Bindestriche (z.B. acme)"
@bind-Value="_firmModel.Subdomain"
Required="true"
Adornment="Adornment.End"
AdornmentText="@GetDomainSuffix()" />
<MudDivider Class="my-2" />
<MudText Typo="Typo.subtitle2" Style="font-weight:700;">Administrator-Konto</MudText>
<MudTextField T="string"
Label="Benutzername"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Person"
@bind-Value="_firmModel.AdminUsername"
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="_firmModel.AdminPassword"
Required="true"
HelperText="Mindestens 6 Zeichen" />
<MudTextField T="string"
Label="Passwort wiederholen"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Lock"
InputType="InputType.Password"
@bind-Value="_firmModel.ConfirmPassword"
Required="true" />
<MudCheckBox @bind-Value="_acceptAgb" Color="Color.Secondary" Class="mt-1">
<MudText Typo="Typo.body2">Ich akzeptiere die <a href="/agb" target="_blank" style="color: var(--mud-palette-secondary); text-decoration: underline; font-weight: 600;">AGB</a>.</MudText>
</MudCheckBox>
<MudButton ButtonType="ButtonType.Submit"
Variant="Variant.Filled"
Color="Color.Secondary"
FullWidth="true"
Size="Size.Large"
StartIcon="@Icons.Material.Filled.Business"
Class="mt-2"
Disabled="_loading || !_acceptAgb">
@if (_loading)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
}
Firma registrieren
</MudButton>
</MudStack>
</EditForm>
</MudStack>
}
@if (!TenantInfo.IsTenant && _activeTab != 2)
{
<MudDivider Class="my-4" />
<MudStack AlignItems="AlignItems.Center">
<MudText Typo="Typo.body2" Color="Color.Secondary">
Sie möchten Timetracker für Ihr Unternehmen nutzen?
</MudText>
<MudButton Variant="Variant.Text" Color="Color.Primary" OnClick="@(() => SetTab(2))" StartIcon="@Icons.Material.Filled.Business">
Eigene Firma registrieren
</MudButton>
</MudStack>
}
}
</MudPaper>
</MudStack>
</MudContainer>
}
@code {
[PersistentState(AllowUpdates = true)]
public TenantInfo? TenantInfo { get; set; }
private int _activeTab = 0;
private string? _error;
private bool _loading;
private string _honeypot = "";
private bool _acceptAgb;
private bool _firmRegisteredSuccessfully;
private string _confirmPassword = "";
private readonly AuthModel _loginModel = new();
private readonly AuthModel _registerModel = new();
private readonly FirmModel _firmModel = new();
[SupplyParameterFromQuery(Name = "error")]
public string? ErrorParam { get; set; }
[SupplyParameterFromQuery(Name = "tab")]
public string? TabParam { get; set; }
protected override async Task OnInitializedAsync()
{
if (TenantInfo is null)
{
try
{
TenantInfo = await Http.GetFromJsonAsync<TenantInfo>("api/tenant/current");
}
catch (Exception ex)
{
Console.WriteLine($"Fehler beim Abrufen der TenantInfo: {ex.Message}");
// Fallback
TenantInfo = new TenantInfo { IsTenant = false };
}
}
}
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 string GetPageTitle()
{
if (TenantInfo == null) return "Lade…";
return TenantInfo.IsTenant ? $"Anmelden bei {TenantInfo.Name} Timetracker" : "Anmelden Timetracker SaaS";
}
private string GetDomainSuffix()
{
var uri = new Uri(Nav.BaseUri);
var host = uri.Host;
return $".{host}";
}
private string GetTenantUrl()
{
var uri = new Uri(Nav.BaseUri);
var scheme = uri.Scheme;
var port = uri.Port == 80 || uri.Port == 443 ? "" : $":{uri.Port}";
var host = uri.Host;
return $"{scheme}://{_firmModel.Subdomain.ToLower().Trim()}.{host}{port}";
}
private void ResetRegistrationFlow()
{
_firmRegisteredSuccessfully = false;
_activeTab = 0;
_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);
}
else
{
_error = "Benutzername oder Passwort falsch.";
}
}
catch (Exception ex)
{
_error = $"Login Fehler: {ex.Message}";
}
finally
{
_loading = false;
}
}
private async Task HandleRegister()
{
if (!_acceptAgb)
{
_error = "Du musst die AGB akzeptieren.";
return;
}
_loading = true;
_error = null;
try
{
var (user, error) = await AuthService.RegisterAsync(_registerModel.Username, _registerModel.Password, _honeypot);
if (user != null)
{
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "showOnboarding", "true");
Nav.NavigateTo("/", forceLoad: true);
}
else
{
_error = error ?? "Registrierung fehlgeschlagen.";
}
}
catch (Exception ex)
{
_error = $"Registrierungs Fehler: {ex.Message}";
}
finally
{
_loading = false;
}
}
private async Task HandleSingleUserRegister()
{
if (!_acceptAgb)
{
_error = "Du musst die AGB akzeptieren.";
return;
}
if (_registerModel.Password != _confirmPassword)
{
_error = "Passwörter stimmen nicht überein.";
return;
}
_loading = true;
_error = null;
try
{
var (user, error) = await AuthService.RegisterAsync(_registerModel.Username, _registerModel.Password, _honeypot);
if (user != null)
{
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "showOnboarding", "true");
Nav.NavigateTo("/", forceLoad: true);
}
else
{
_error = error ?? "Registrierung fehlgeschlagen.";
}
}
catch (Exception ex)
{
_error = $"Registrierungs Fehler: {ex.Message}";
}
finally
{
_loading = false;
}
}
private async Task HandleRegisterFirm()
{
if (!_acceptAgb)
{
_error = "Du musst die AGB akzeptieren.";
return;
}
if (_firmModel.AdminPassword != _firmModel.ConfirmPassword)
{
_error = "Passwörter stimmen nicht überein.";
return;
}
_loading = true;
_error = null;
try
{
var req = new RegisterFirmRequest(
_firmModel.CompanyName,
_firmModel.Subdomain,
_firmModel.AdminUsername,
_firmModel.AdminPassword
);
var (tenant, error) = await AuthService.RegisterFirmAsync(req);
if (tenant != null)
{
_firmRegisteredSuccessfully = true;
}
else
{
_error = error ?? "Registrierung der Firma 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; } = "";
}
private class FirmModel
{
public string CompanyName { get; set; } = "";
public string Subdomain { get; set; } = "";
public string AdminUsername { get; set; } = "";
public string AdminPassword { get; set; } = "";
public string ConfirmPassword { get; set; } = "";
}
}
@@ -1,179 +0,0 @@
@inject IJSRuntime JS
@if (_active)
{
@* Dark Backdrop Overlay *@
<div class="onboarding-backdrop"></div>
@* Onboarding Instruction Card *@
<div class="onboarding-card-container">
<MudCard Class="onboarding-info-card pa-5 rounded-xl shadow-2xl">
<MudCardContent Class="pa-0">
<MudStack Spacing="3">
@* Header / Title *@
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.AutoAwesome" Style="color: #0EA5E9;" />
<MudText Typo="Typo.h6" Style="font-weight: 700; color: white;">
@_steps[_currentStep].Title
</MudText>
</MudStack>
@* Explanation Text *@
<MudText Typo="Typo.body2" Style="color: #CBD5E1; line-height: 1.6; min-height: 60px;">
@_steps[_currentStep].Content
</MudText>
@* Footer / Progress & Buttons *@
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mt-2">
@* Step Count *@
<MudText Typo="Typo.caption" Style="color: #94A3B8; font-weight: 600;">
Schritt @(_currentStep + 1) von @_steps.Count
</MudText>
@* Navigation Button Stack *@
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
@if (_currentStep > 0)
{
<MudButton Variant="Variant.Text"
Style="color: #94A3B8; text-transform: none; border-radius: 12px;"
OnClick="PrevStep"
Size="Size.Small">
Zurück
</MudButton>
}
else
{
<MudButton Variant="Variant.Text"
Style="color: #94A3B8; text-transform: none; border-radius: 12px;"
OnClick="CompleteTour"
Size="Size.Small">
Überspringen
</MudButton>
}
<MudButton Variant="Variant.Filled"
Color="Color.Secondary"
Style="text-transform: none; border-radius: 12px; font-weight: 700; min-width: 80px;"
OnClick="NextStep"
Size="Size.Small">
@(_currentStep == _steps.Count - 1 ? "Fertig" : "Weiter")
</MudButton>
</MudStack>
</MudStack>
</MudStack>
</MudCardContent>
</MudCard>
</div>
}
<style>
/* Backdrop behind cards but below the spotlight highlight */
.onboarding-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(15, 23, 42, 0.4);
backdrop-filter: blur(2px);
z-index: 9999;
pointer-events: all;
}
/* Fixed positioned instruction box at the bottom center */
.onboarding-card-container {
position: fixed;
bottom: 32px;
left: 50%;
transform: translateX(-50%);
width: 90%;
max-width: 480px;
z-index: 10001;
transition: all 0.3s ease;
}
/* Dark theme styling for the instruction card */
.onboarding-info-card {
background: rgba(15, 23, 42, 0.9) !important;
backdrop-filter: blur(16px) !important;
-webkit-backdrop-filter: blur(16px) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.4) !important;
}
</style>
@code {
[Parameter] public EventCallback OnFinished { get; set; }
private bool _active = true;
private int _currentStep = 0;
private readonly List<OnboardingStep> _steps =
[
new(".onboarding-nav-menu", "Die Navigation", "Hier auf der linken Seite findest du das Hauptmenü. Du kannst ganz einfach zwischen der Wochen- und Monatsübersicht wechseln oder deine Statistiken einsehen."),
new(".onboarding-week-header", "Kalenderwoche blättern", "Hier siehst du die aktuell ausgewählte Woche. Verwende die Pfeiltasten links und rechts, um in vergangenen oder zukünftigen Wochen deine Zeiten zu erfassen."),
new(".onboarding-day-card", "Zeiterfassung pro Tag", "Das Herzstück des Timetrackers. Trage hier deine täglichen Start- und Endzeiten sowie deine Pausen ein. Das System errechnet die Zeiten und Überstunden in Echtzeit."),
new(".onboarding-week-summary", "Wochenzusammenfassung", "Hier siehst du auf einen Blick, wie viele Stunden du in dieser Woche gearbeitet hast, wie viel Pause du gemacht hast und wie sich dein Stundensaldo verändert hat."),
new(".onboarding-overtime-balance", "Gleitzeitkonto", "Hier wird dein gesamtes Gleitzeitkonto über das Jahr hinweg zusammengerechnet. So weißt du immer genau, wie viele Überstunden du angesammelt hast.")
];
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await HighlightStep();
}
}
private async Task HighlightStep()
{
if (_active && _steps.Count > 0)
{
var step = _steps[_currentStep];
await JS.InvokeVoidAsync("window.onboarding.highlight", step.TargetSelector);
}
}
private async Task NextStep()
{
if (_currentStep < _steps.Count - 1)
{
_currentStep++;
await HighlightStep();
}
else
{
await CompleteTour();
}
}
private async Task PrevStep()
{
if (_currentStep > 0)
{
_currentStep--;
await HighlightStep();
}
}
private async Task CompleteTour()
{
_active = false;
await JS.InvokeVoidAsync("window.onboarding.clear");
await OnFinished.InvokeAsync();
}
private class OnboardingStep
{
public string TargetSelector { get; }
public string Title { get; }
public string Content { get; }
public OnboardingStep(string targetSelector, string title, string content)
{
TargetSelector = targetSelector;
Title = title;
Content = content;
}
}
}
@@ -1,315 +0,0 @@
@page "/profile"
@rendermode InteractiveWebAssembly
@attribute [Authorize]
@using System.Security.Claims
@inject IAuthService AuthService
@inject ITimetrackerService TrackerService
@inject ISnackbar Snackbar
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Nav
<PageTitle>Benutzerprofil Timetracker</PageTitle>
@if (_loading)
{
<MudStack AlignItems="AlignItems.Center" Class="mt-16">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
<MudText Color="Color.Secondary">Lade Profil…</MudText>
</MudStack>
}
else
{
<MudStack Spacing="4">
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudIcon Icon="@Icons.Material.Filled.AccountCircle" Style="color: var(--mud-palette-primary-text); font-size:2.5rem" />
<MudStack Spacing="0">
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700">Mein Profil</MudText>
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72;">
Persönliche Details verwalten und Account sichern
</MudText>
</MudStack>
</MudStack>
</MudPaper>
<MudGrid Spacing="4">
@* ── Übersicht & Details ── *@
<MudItem xs="12" md="4">
<MudCard Elevation="3" Class="rounded-xl h-100">
<MudCardContent>
<MudStack AlignItems="AlignItems.Center" Class="py-6" Spacing="3">
<MudAvatar Style="width:96px; height:96px; font-size:2rem; font-weight:700; background: linear-gradient(135deg, #0EA5E9 0%, #0284C7 100%); color: white;">
@GetInitials(_username)
</MudAvatar>
<MudStack AlignItems="AlignItems.Center" Spacing="0">
<MudText Typo="Typo.h6" Style="font-weight:700">@_username</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">@(_isAdmin ? "Administrator" : "Standardbenutzer")</MudText>
</MudStack>
</MudStack>
<MudDivider Class="my-3" />
<MudStack Spacing="2" Class="pa-2">
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Color="Color.Secondary">Benutzer-ID</MudText>
<MudText Typo="Typo.body2"><b>#@_userId</b></MudText>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Color="Color.Secondary">Gleitzeit-Saldo</MudText>
<MudText Typo="Typo.body2" Color="@(_overtime >= TimeSpan.Zero ? Color.Success : Color.Warning)">
<b>@FormatTs(_overtime, sign: true)</b>
</MudText>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Color="Color.Secondary">Sollstunden / Tag</MudText>
<MudText Typo="Typo.body2"><b>@_settings.DailyTargetHours h</b></MudText>
</MudStack>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
@* ── Einstellungen verwalten ── *@
<MudItem xs="12" md="8">
<MudStack Spacing="4">
@* Benutzername ändern *@
<MudCard Elevation="3" Class="rounded-xl">
<MudCardHeader>
<CardHeaderContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.Badge" Color="Color.Primary" />
<MudText Typo="Typo.h6" Style="font-weight:600">Benutzername ändern</MudText>
</MudStack>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudForm @ref="_renameForm" @bind-IsValid="_renameIsValid">
<MudStack Spacing="3">
<MudTextField @bind-Value="_newUsername"
Label="Neuer Benutzername"
Variant="Variant.Outlined"
Required="true"
RequiredError="Bitte gib einen Benutzernamen ein."
Validation="@(new Func<string, string?>(ValidateUsername))" />
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="HandleRename"
Disabled="!_renameIsValid || _newUsername == _username"
Class="align-self-start rounded-lg py-2 px-4"
StartIcon="@Icons.Material.Filled.Save">
Speichern
</MudButton>
</MudStack>
</MudForm>
</MudCardContent>
</MudCard>
@* Passwort ändern *@
<MudCard Elevation="3" Class="rounded-xl">
<MudCardHeader>
<CardHeaderContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.Lock" Color="Color.Primary" />
<MudText Typo="Typo.h6" Style="font-weight:600">Passwort ändern</MudText>
</MudStack>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudForm @ref="_passwordForm" @bind-IsValid="_passwordIsValid">
<MudStack Spacing="3">
<MudTextField @bind-Value="_currentPassword"
Label="Aktuelles Passwort"
InputType="InputType.Password"
Variant="Variant.Outlined"
Required="true"
RequiredError="Bitte gib dein aktuelles Passwort ein." />
<MudTextField @bind-Value="_newPassword"
Label="Neues Passwort"
InputType="InputType.Password"
Variant="Variant.Outlined"
Required="true"
RequiredError="Bitte gib dein neues Passwort ein."
Validation="@(new Func<string, string?>(ValidatePassword))" />
<MudTextField @bind-Value="_confirmPassword"
Label="Neues Passwort bestätigen"
InputType="InputType.Password"
Variant="Variant.Outlined"
Required="true"
RequiredError="Bitte bestätige dein neues Passwort."
Validation="@(new Func<string, string?>(ValidateConfirmPassword))" />
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="HandleChangePassword"
Disabled="!_passwordIsValid"
Class="align-self-start rounded-lg py-2 px-4"
StartIcon="@Icons.Material.Filled.VpnKey">
Passwort ändern
</MudButton>
</MudStack>
</MudForm>
</MudCardContent>
</MudCard>
</MudStack>
</MudItem>
</MudGrid>
</MudStack>
}
@code {
[PersistentState(AllowUpdates = true)]
public AppSettings? PersistentSettings { get; set; }
[PersistentState(AllowUpdates = true)]
public string? PersistentUsername { get; set; }
[PersistentState(AllowUpdates = true)]
public string? PersistentNewUsername { get; set; }
[PersistentState(AllowUpdates = true)]
public bool? PersistentIsAdmin { get; set; }
[PersistentState(AllowUpdates = true)]
public TimeSpan? PersistentOvertime { get; set; }
private bool _loading = true;
private int _userId;
private string _username = "";
private string _newUsername = "";
private bool _isAdmin;
private AppSettings _settings = new();
private TimeSpan _overtime;
// Formulare
private MudForm? _renameForm;
private bool _renameIsValid;
private MudForm? _passwordForm;
private bool _passwordIsValid;
// Passwörter
private string _currentPassword = "";
private string _newPassword = "";
private string _confirmPassword = "";
protected override async Task OnInitializedAsync()
{
if (PersistentSettings != null)
{
_settings = PersistentSettings;
_username = PersistentUsername ?? "";
_newUsername = PersistentNewUsername ?? "";
_isAdmin = PersistentIsAdmin ?? false;
_overtime = PersistentOvertime ?? TimeSpan.Zero;
_loading = false;
}
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return;
_userId = int.Parse(claim.Value);
if (PersistentSettings == null)
{
_username = authState.User.Identity?.Name ?? "";
_newUsername = _username;
_isAdmin = _username.Equals("marc", StringComparison.OrdinalIgnoreCase);
_settings = await TrackerService.GetSettingsAsync(_userId);
_overtime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
PersistentSettings = _settings;
PersistentUsername = _username;
PersistentNewUsername = _newUsername;
PersistentIsAdmin = _isAdmin;
PersistentOvertime = _overtime;
_loading = false;
}
}
private string GetInitials(string? name)
{
if (string.IsNullOrWhiteSpace(name)) return "U";
var parts = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 1) return parts[0][..Math.Min(2, parts[0].Length)].ToUpper();
return $"{parts[0][0]}{parts[1][0]}".ToUpper();
}
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}";
}
// Validierungen
private string? ValidateUsername(string val)
{
if (string.IsNullOrWhiteSpace(val) || val.Length < 3)
return "Benutzername muss mindestens 3 Zeichen lang sein.";
return null;
}
private string? ValidatePassword(string val)
{
if (string.IsNullOrWhiteSpace(val) || val.Length < 6)
return "Passwort muss mindestens 6 Zeichen lang sein.";
return null;
}
private string? ValidateConfirmPassword(string val)
{
if (val != _newPassword)
return "Die Passwörter stimmen nicht überein.";
return null;
}
// Aktionen
private async Task HandleRename()
{
if (_renameForm == null) return;
await _renameForm.ValidateAsync();
if (!_renameForm.IsValid) return;
var error = await AuthService.RenameUserAsync(_userId, _newUsername);
if (error != null)
{
Snackbar.Add(error, Severity.Error);
}
else
{
Snackbar.Add("Benutzername erfolgreich geändert. Bitte melde dich ggf. neu an, um alle Anzeigen zu aktualisieren.", Severity.Success);
_username = _newUsername;
// Umleitung zur Startseite, um die neue Identity zu laden
Nav.NavigateTo("/", forceLoad: true);
}
}
private async Task HandleChangePassword()
{
if (_passwordForm == null) return;
await _passwordForm.ValidateAsync();
if (!_passwordForm.IsValid) return;
var error = await AuthService.ChangePasswordAsync(_userId, _currentPassword, _newPassword);
if (error != null)
{
Snackbar.Add(error, Severity.Error);
}
else
{
Snackbar.Add("Passwort erfolgreich geändert.", Severity.Success);
_currentPassword = "";
_newPassword = "";
_confirmPassword = "";
if (_passwordForm != null) await _passwordForm.ResetAsync();
}
}
}
@@ -1,29 +0,0 @@
@namespace timetracker.Client.Components.Pages
@inject ISnackbar Snackbar
<MudDialog>
<DialogContent>
<MudText Class="mb-4">Geben Sie das neue Passwort für den Benutzer <strong>@Username</strong> ein.</MudText>
<MudTextField @bind-Value="_newPassword"
Label="Neues Passwort"
InputType="InputType.Password"
Variant="Variant.Outlined"
AutoFocus="true"
Required="true"
HelperText="Mindestens 6 Zeichen" />
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Abbrechen</MudButton>
<MudButton Color="Color.Warning" Variant="Variant.Filled" OnClick="Submit" Disabled="@(string.IsNullOrEmpty(_newPassword) || _newPassword.Length < 6)">Passwort zurücksetzen</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!;
[Parameter] public string Username { get; set; } = "";
private string _newPassword = "";
private void Submit() => MudDialog.Close(DialogResult.Ok(_newPassword));
private void Cancel() => MudDialog.Cancel();
}
@@ -1,524 +0,0 @@
@page "/stats"
@rendermode InteractiveWebAssembly
@attribute [Authorize]
@inject ITimetrackerService TrackerService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@using System.Security.Claims
@using timetracker.Shared
<PageTitle>Statistiken 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 Statistiken…</MudText>
</MudStack>
}
else
{
<MudStack Spacing="4">
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudIcon Icon="@Icons.Material.Filled.BarChart" Style="color: var(--mud-palette-primary-text); font-size:2.2rem" />
<MudStack Spacing="0">
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700">Statistiken</MudText>
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">Auswertung deiner Arbeitsleistung und Gleitzeitkonto</MudText>
</MudStack>
</MudStack>
</MudPaper>
<MudGrid Spacing="4">
@* ── Card 1: Wochen-Fortschritt ── *@
<MudItem xs="12" md="4">
<MudCard Elevation="3" Class="rounded-xl" Style="height:100%;">
<MudCardContent>
<MudStack AlignItems="AlignItems.Center" Spacing="3">
<MudText Typo="Typo.subtitle1" Style="font-weight:700; color:#475569">Wochenfortschritt</MudText>
@{
var pct = _weekTargetHours > 0 ? (int)Math.Min((_weekWorkedHours / _weekTargetHours) * 100, 100) : 0;
var displayPct = _weekTargetHours > 0 ? (int)Math.Round((_weekWorkedHours / _weekTargetHours) * 100) : 0;
}
<div style="position:relative; display:inline-flex;">
<MudProgressCircular Value="@pct" Color="Color.Secondary" Size="Size.Large" StrokeWidth="6" Style="height:120px; width:120px;" />
<div style="position:absolute; top:0; left:0; bottom:0; right:0; display:flex; align-items:center; justify-content:center; flex-direction:column;">
<MudText Typo="Typo.h5" Style="font-weight:800; color: var(--mud-palette-text-primary);">@displayPct%</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Erreicht</MudText>
</div>
</div>
<MudStack Spacing="1" Style="width:100%;" Class="mt-2">
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Color="Color.Secondary">Arbeitszeit (Ist):</MudText>
<MudText Typo="Typo.body2" Style="font-weight:700; color: var(--mud-palette-text-primary)">@FormatHours(_weekWorkedHours) Std.</MudText>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Color="Color.Secondary">Wochensoll (Soll):</MudText>
<MudText Typo="Typo.body2" Style="font-weight:700">@FormatHours(_weekTargetHours) Std.</MudText>
</MudStack>
</MudStack>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
@* ── Card 2: Wochensaldo ── *@
<MudItem xs="12" sm="6" md="4">
<MudCard Elevation="3" Class="rounded-xl" Style="height:100%; border-left: 6px solid var(--mud-palette-primary);">
<MudCardContent>
<MudStack Spacing="2">
<MudText Typo="Typo.subtitle1" Style="font-weight:700; color:#475569">Wochensaldo</MudText>
<MudStack Row="true" AlignItems="AlignItems.Center" Class="py-3">
<MudIcon Icon="@(_weekOvertimeHours >= 0 ? Icons.Material.Filled.TrendingUp : Icons.Material.Filled.TrendingDown)"
Style="@($"color:{(_weekOvertimeHours >= 0 ? "#10B981" : "#EF4444")}; font-size:3rem")" />
<MudStack Spacing="0">
<MudText Typo="Typo.h3" Style="@($"font-weight:800; color:{(_weekOvertimeHours >= 0 ? "#10B981" : "#EF4444")};")">
@FormatTs(TimeSpan.FromHours(_weekOvertimeHours), sign: true)
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Überstunden-Veränderung diese Woche</MudText>
</MudStack>
</MudStack>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
@* ── Card 3: Gesamtsaldo ── *@
<MudItem xs="12" sm="6" md="4">
<MudCard Elevation="3" Class="rounded-xl" Style="@($"height:100%; border-left: 6px solid {(_totalOvertime >= TimeSpan.Zero ? "#10B981" : "#EF4444")};")">
<MudCardContent>
<MudStack Spacing="2">
<MudText Typo="Typo.subtitle1" Style="font-weight:700; color:#475569">Gleitzeitkonto</MudText>
<MudStack Row="true" AlignItems="AlignItems.Center" Class="py-3">
<MudIcon Icon="Icons.Material.Filled.AccountBalance"
Style="@($"color:{(_totalOvertime >= TimeSpan.Zero ? "#10B981" : "#EF4444")}; font-size:3rem")" />
<MudStack Spacing="0">
<MudText Typo="Typo.h3" Style="@($"font-weight:800; color:{(_totalOvertime >= TimeSpan.Zero ? "#10B981" : "#EF4444")};")">
@FormatTs(_totalOvertime, sign: true)
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Gesamtsaldo aller erfassten Tage</MudText>
</MudStack>
</MudStack>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
@* ── Säulendiagramm (Tagesverteilung) ── *@
<MudItem xs="12">
<MudCard Elevation="3" Class="rounded-xl">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6" Style="font-weight:700; color: var(--mud-palette-text-primary)">Arbeitszeitverteilung diese Woche</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Geleistete Nettoarbeitsstunden pro Wochentag</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<div class="chart-container" style="width:100%; overflow-x:auto;">
<svg viewBox="0 0 640 320" width="100%" height="320" style="min-width: 500px; display: block; overflow: visible;">
<style>
.chart-bar {
transition: height 0.4s ease, y 0.4s ease, fill 0.3s;
cursor: pointer;
}
.chart-bar:hover {
filter: brightness(1.1) drop-shadow(0px 4px 8px rgba(14, 165, 233, 0.3));
}
</style>
@{
double plotLeft = 50;
double plotRight = 580;
double plotTop = 30;
double plotBottom = 270;
double plotWidth = plotRight - plotLeft;
double plotHeight = plotBottom - plotTop;
double barWidth = 36;
double spacing = plotWidth / 7;
}
<!-- Horizontal Grid Lines -->
@for (int i = 0; i <= 4; i++)
{
double val = (_maxHoursValue / 4.0) * i;
double yPos = plotBottom - (val / _maxHoursValue) * plotHeight;
<line x1="@F(plotLeft)" y1="@F(yPos)" x2="@F(plotRight)" y2="@F(yPos)" stroke="#E2E8F0" stroke-width="1" stroke-dasharray="2" />
@((MarkupString)$"<text x=\"{F(plotLeft - 8)}\" y=\"{F(yPos + 4)}\" fill=\"#64748B\" font-size=\"11\" font-weight=\"500\" text-anchor=\"end\">{val:F1}h</text>")
}
<!-- Bars and Labels -->
@for (int i = 0; i < 7; i++)
{
var d = _days[i];
double xPos = plotLeft + (i * spacing) + (spacing - barWidth) / 2;
double bHeight = (d.WorkedHours / _maxHoursValue) * plotHeight;
double yPos = plotBottom - bHeight;
string fillCol = "#94A3B8"; // Default light slate
if (d.IsToday)
{
fillCol = "var(--mud-palette-secondary)"; // Brand color for today
}
else if (d.WorkedHours >= d.TargetHours && d.TargetHours > 0)
{
fillCol = "#475569"; // Slate Blue for normal completed target
}
else if (d.WorkedHours > 0 && d.TargetHours == 0)
{
fillCol = "#10B981"; // Emerald Green for weekend work (pure overtime)
}
else if (d.WorkedHours > 0)
{
fillCol = "#64748B"; // Slate Medium for incomplete target
}
else
{
fillCol = "#E2E8F0"; // Very light grey for zero hours
}
<!-- Render Rect with hover tooltip -->
<rect x="@F(xPos)" y="@F(d.WorkedHours > 0 ? yPos : plotBottom - 4)" width="@F(barWidth)" height="@F(d.WorkedHours > 0 ? bHeight : 4)"
rx="6" fill="@fillCol" class="chart-bar">
<title>@d.DayName: @FormatHours(d.WorkedHours) Std. (Soll: @FormatHours(d.TargetHours) Std.)</title>
</rect>
<!-- Value Label above bar -->
@if (d.WorkedHours > 0)
{
@((MarkupString)$"<text x=\"{F(xPos + barWidth / 2)}\" y=\"{F(yPos - 6)}\" fill=\"var(--mud-palette-text-primary)\" font-size=\"11\" font-weight=\"700\" text-anchor=\"middle\">{FormatHours(d.WorkedHours)}</text>")
}
<!-- Wochentag Text -->
@((MarkupString)$"<text x=\"{F(xPos + barWidth / 2)}\" y=\"{F(plotBottom + 20)}\" fill=\"#64748B\" font-size=\"11\" font-weight=\"700\" text-anchor=\"middle\">{d.DayShortName}</text>")
}
<!-- Sollzeit Target Line (Only if target hours > 0) -->
@if (_settings.DailyTargetHours > 0)
{
double yTarget = plotBottom - (_settings.DailyTargetHours / _maxHoursValue) * plotHeight;
<line x1="@F(plotLeft)" y1="@F(yTarget)" x2="@F(plotRight)" y2="@F(yTarget)" stroke="#EF4444" stroke-width="2" stroke-dasharray="4" style="opacity: 0.75;" />
@((MarkupString)$"<text x=\"{F(plotRight - 10)}\" y=\"{F(yTarget - 6)}\" fill=\"#EF4444\" font-size=\"10\" font-weight=\"700\" text-anchor=\"end\">Soll ({_settings.DailyTargetHours} h)</text>")
}
</svg>
</div>
</MudCardContent>
</MudCard>
</MudItem>
@* ── Excel Export ── *@
<MudItem xs="12">
<MudCard Elevation="3" Class="rounded-xl">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6" Style="font-weight:700; color: var(--mud-palette-text-primary)">Excel-Datenexport</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Lade deine erfassten Arbeitszeiten in verschiedenen Zeiträumen als formatierte Excel-Datei herunter.</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudGrid Spacing="3">
@* Wochenexport *@
<MudItem xs="12" sm="4">
<MudStack Spacing="3">
<MudText Typo="Typo.subtitle2" Style="font-weight:700">Wochenexport</MudText>
<MudDatePicker Label="Woche wählen" @bind-Date="_exportWeekDate" PickerVariant="PickerVariant.Dialog" Variant="Variant.Outlined" Color="Color.Primary" />
<MudButton StartIcon="@Icons.Material.Filled.Download" Variant="Variant.Filled" Color="Color.Primary" OnClick="ExportWeek" FullWidth="true">
Woche exportieren
</MudButton>
</MudStack>
</MudItem>
@* Monatsexport *@
<MudItem xs="12" sm="4">
<MudStack Spacing="3">
<MudText Typo="Typo.subtitle2" Style="font-weight:700">Monatsexport</MudText>
<MudStack Row="true" Spacing="2">
<MudSelect T="int" Label="Monat" @bind-Value="_exportMonth" Variant="Variant.Outlined">
@for (int m = 1; m <= 12; m++)
{
var monthVal = m;
<MudSelectItem Value="@monthVal">@_deCulture.DateTimeFormat.GetMonthName(monthVal)</MudSelectItem>
}
</MudSelect>
<MudSelect T="int" Label="Jahr" @bind-Value="_exportMonthYear" Variant="Variant.Outlined">
@for (int y = DateTime.Today.Year - 5; y <= DateTime.Today.Year + 1; y++)
{
var yearVal = y;
<MudSelectItem Value="@yearVal">@yearVal</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudButton StartIcon="@Icons.Material.Filled.Download" Variant="Variant.Filled" Color="Color.Primary" OnClick="ExportMonth" FullWidth="true">
Monat exportieren
</MudButton>
</MudStack>
</MudItem>
@* Jahresexport *@
<MudItem xs="12" sm="4">
<MudStack Spacing="3">
<MudText Typo="Typo.subtitle2" Style="font-weight:700">Jahresexport</MudText>
<MudSelect T="int" Label="Jahr" @bind-Value="_exportYear" Variant="Variant.Outlined">
@for (int y = DateTime.Today.Year - 5; y <= DateTime.Today.Year + 1; y++)
{
var yearVal = y;
<MudSelectItem Value="@yearVal">@yearVal</MudSelectItem>
}
</MudSelect>
<MudButton StartIcon="@Icons.Material.Filled.Download" Variant="Variant.Filled" Color="Color.Primary" OnClick="ExportYear" FullWidth="true">
Jahr exportieren
</MudButton>
</MudStack>
</MudItem>
</MudGrid>
</MudCardContent>
</MudCard>
</MudItem>
@* ── Diagnostics Panel ── *@
<MudItem xs="12">
<MudExpansionPanels>
<MudExpansionPanel Text="Diagnose-Daten (Debug)">
<MudText Typo="Typo.body2">Benutzer-ID: @_userId</MudText>
<MudText Typo="Typo.body2">Wochensoll: @_weekTargetHours Std. | Netto-Arbeitszeit: @_weekWorkedHours Std. | Wochensaldo: @_weekOvertimeHours Std.</MudText>
<MudText Typo="Typo.body2">Anzahl erfasste Tage aus DB: @_rawDbDaysCount</MudText>
<MudSimpleTable Style="margin-top: 10px;">
<thead>
<tr>
<th>Datum</th>
<th>Tag</th>
<th>Ist (Std)</th>
<th>Soll (Std)</th>
<th>Ist > 0</th>
<th>Arbeitstag (Einst.)</th>
<th>Aus DB zugeordnet</th>
</tr>
</thead>
<tbody>
@foreach (var d in _days)
{
<tr>
<td>@d.Date.ToString("yyyy-MM-dd")</td>
<td>@d.DayName</td>
<td>@d.WorkedHours</td>
<td>@d.TargetHours</td>
<td>@(d.WorkedHours > 0 ? "Ja" : "Nein")</td>
<td>@(d.IsWorkDay ? "Ja" : "Nein")</td>
<td>@(_dbMatchStatus.GetValueOrDefault(d.Date, "Nein"))</td>
</tr>
}
</tbody>
</MudSimpleTable>
</MudExpansionPanel>
</MudExpansionPanels>
</MudItem>
</MudGrid>
</MudStack>
}
@code {
[PersistentState(AllowUpdates = true)]
public AppSettings? PersistentSettings { get; set; }
[PersistentState(AllowUpdates = true)]
public List<DayStat>? PersistentDays { get; set; }
[PersistentState(AllowUpdates = true)]
public TimeSpan? PersistentTotalOvertime { get; set; }
[PersistentState(AllowUpdates = true)]
public double? PersistentWeekWorkedHours { get; set; }
[PersistentState(AllowUpdates = true)]
public double? PersistentWeekTargetHours { get; set; }
[PersistentState(AllowUpdates = true)]
public double? PersistentWeekOvertimeHours { get; set; }
[PersistentState(AllowUpdates = true)]
public double? PersistentMaxHoursValue { get; set; }
private bool _loading = true;
private int _userId;
private AppSettings _settings = new();
private List<DayStat> _days = [];
private TimeSpan _totalOvertime;
private double _weekWorkedHours;
private double _weekTargetHours;
private double _weekOvertimeHours;
private double _maxHoursValue = 10.0;
// Excel Export variables
private DateTime? _exportWeekDate = DateTime.Today;
private int _exportMonth = DateTime.Today.Month;
private int _exportMonthYear = DateTime.Today.Year;
private int _exportYear = DateTime.Today.Year;
// Debug variables
private int _rawDbDaysCount = 0;
private Dictionary<DateOnly, string> _dbMatchStatus = new();
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
private static string F(double value) => value.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
protected override async Task OnInitializedAsync()
{
if (PersistentSettings != null && PersistentDays != null)
{
_settings = PersistentSettings;
_days = PersistentDays;
_totalOvertime = PersistentTotalOvertime ?? TimeSpan.Zero;
_weekWorkedHours = PersistentWeekWorkedHours ?? 0.0;
_weekTargetHours = PersistentWeekTargetHours ?? 0.0;
_weekOvertimeHours = PersistentWeekOvertimeHours ?? 0.0;
_maxHoursValue = PersistentMaxHoursValue ?? 10.0;
_loading = false;
}
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return;
_userId = int.Parse(claim.Value);
if (PersistentSettings == null || PersistentDays == null)
{
_settings = await TrackerService.GetSettingsAsync(_userId);
var today = DateOnly.FromDateTime(DateTime.Today);
var monday = GetMonday(today);
var dbDays = await TrackerService.GetWeekAsync(_userId, monday);
_rawDbDaysCount = dbDays.Count;
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
_weekTargetHours = 0;
_weekWorkedHours = 0;
_dbMatchStatus.Clear();
_days = Enumerable.Range(0, 7).Select(i =>
{
var date = monday.AddDays(i);
bool isWorkDay = _settings.IsWorkDay(date.DayOfWeek);
double target = isWorkDay ? _settings.DailyTargetHours : 0.0;
if (isWorkDay)
{
_weekTargetHours += _settings.DailyTargetHours;
}
var wd = dbDays.FirstOrDefault(d => d.Date == date);
double worked = 0.0;
if (wd != null)
{
_dbMatchStatus[date] = $"Ja (Id={wd.Id}, Start={wd.StartTime}, Ende={wd.EndTime})";
if (wd.StartTime != null && wd.EndTime != null)
{
var gross = wd.EndTime.Value.ToTimeSpan() - wd.StartTime.Value.ToTimeSpan();
if (gross > TimeSpan.Zero)
{
var breakTotal = 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()));
var minMinutes = gross.TotalHours >= 9.0 ? 45 : 30;
var minBreak = TimeSpan.FromMinutes(Math.Max(minMinutes, _settings.MinimumBreakMinutes));
var finalBreak = breakTotal > minBreak ? breakTotal : minBreak;
worked = (gross - finalBreak).TotalHours;
if (worked < 0) worked = 0.0;
}
}
}
else
{
_dbMatchStatus[date] = "Nein";
}
_weekWorkedHours += worked;
return new DayStat
{
Date = date,
DayName = date.ToString("dddd", _deCulture),
DayShortName = date.ToString("ddd", _deCulture),
WorkedHours = worked,
TargetHours = target,
IsToday = date == today,
IsWorkDay = isWorkDay
};
}).ToList();
_weekOvertimeHours = _weekWorkedHours - _weekTargetHours;
var maxWorked = _days.Max(d => d.WorkedHours);
_maxHoursValue = Math.Max(10.0, Math.Max(maxWorked, _settings.DailyTargetHours));
PersistentSettings = _settings;
PersistentDays = _days;
PersistentTotalOvertime = _totalOvertime;
PersistentWeekWorkedHours = _weekWorkedHours;
PersistentWeekTargetHours = _weekTargetHours;
PersistentWeekOvertimeHours = _weekOvertimeHours;
PersistentMaxHoursValue = _maxHoursValue;
_loading = false;
}
}
private static DateOnly GetMonday(DateOnly date)
{
int diff = ((int)date.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7;
return date.AddDays(-diff);
}
private string FormatHours(double hours)
{
var ts = TimeSpan.FromHours(hours);
return $"{(int)ts.TotalHours}:{ts.Minutes:D2}";
}
private 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}";
}
private void ExportWeek()
{
if (_exportWeekDate.HasValue)
{
var date = DateOnly.FromDateTime(_exportWeekDate.Value);
NavigationManager.NavigateTo($"api/tracker/export/week?monday={date:yyyy-MM-dd}", forceLoad: true);
}
}
private void ExportMonth()
{
NavigationManager.NavigateTo($"api/tracker/export/month?year={_exportMonthYear}&month={_exportMonth}", forceLoad: true);
}
private void ExportYear()
{
NavigationManager.NavigateTo($"api/tracker/export/year?year={_exportYear}", forceLoad: true);
}
public sealed class DayStat
{
public DateOnly Date { get; set; }
public string DayName { get; set; } = "";
public string DayShortName { get; set; } = "";
public double WorkedHours { get; set; }
public double TargetHours { get; set; }
public bool IsToday { get; set; }
public bool IsWorkDay { get; set; }
}
}
@@ -1,279 +0,0 @@
@page "/superadmin"
@rendermode InteractiveWebAssembly
@attribute [Authorize(Policy = "AdminOnly")]
@using System.Net.Http.Json
@inject HttpClient Http
@inject ISnackbar Snackbar
@inject IDialogService DialogService
<PageTitle>Firmenverwaltung Timetracker SaaS</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 Firmen und Mandanten…</MudText>
</MudStack>
}
else
{
<MudStack Spacing="4">
@* ── Header Card ── *@
<MudPaper Elevation="4" Class="pa-6 rounded-xl"
Style="background: linear-gradient(135deg, var(--mud-palette-primary-darken) 0%, var(--mud-palette-primary) 100%); color: var(--mud-palette-primary-text);">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="4">
<MudIcon Icon="@Icons.Material.Filled.Business" Style="color: var(--mud-palette-secondary); font-size:3rem" />
<MudStack Spacing="0">
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:800">SuperAdmin Dashboard</MudText>
<MudText Typo="Typo.body2" Style="color: var(--mud-palette-primary-text); opacity: 0.72">
@_tenants.Count registrierte Firmen im System
</MudText>
</MudStack>
</MudStack>
</MudPaper>
@* ── Tenants Table ── *@
<MudCard Elevation="3" Class="rounded-xl overflow-hidden">
<MudCardContent Class="pa-0">
<MudTable Items="_tenants" Hover="true" Striped="true" Elevation="0" SortLabel="Sortieren">
<HeaderContent>
<MudTh Style="width: 50px;" />
<MudTh><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.Id)">ID</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.Name)">Name</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.Subdomain)">Subdomain</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.CreatedAt)">Erstellt am</MudTableSortLabel></MudTh>
<MudTh Style="text-align:center"><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.UserCount)">Benutzer</MudTableSortLabel></MudTh>
<MudTh Style="text-align:center"><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.WorkDaysCount)">Arbeitstage</MudTableSortLabel></MudTh>
<MudTh Style="text-align:center">Status</MudTh>
<MudTh Style="text-align:right">Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudIconButton Icon="@(context.ShowDetails ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)"
Size="Size.Small"
OnClick="@(() => context.ShowDetails = !context.ShowDetails)" />
</MudTd>
<MudTd DataLabel="ID">
<MudText Typo="Typo.body2" Color="Color.Secondary">@context.Id</MudText>
</MudTd>
<MudTd DataLabel="Name">
<MudText Typo="Typo.body1" Style="font-weight:600">@context.Name</MudText>
</MudTd>
<MudTd DataLabel="Subdomain">
<MudChip T="string" Color="Color.Primary" Variant="Variant.Outlined" Size="Size.Small">
@context.Subdomain
</MudChip>
</MudTd>
<MudTd DataLabel="Erstellt am">
<MudText Typo="Typo.body2">@context.CreatedAt.ToLocalTime().ToString("dd.MM.yyyy HH:mm")</MudText>
</MudTd>
<MudTd DataLabel="Benutzer" Style="text-align:center">
<MudBadge Content="@context.UserCount" Color="Color.Default" Overlap="false" Class="mx-2" />
</MudTd>
<MudTd DataLabel="Arbeitstage" Style="text-align:center">
<MudText Typo="Typo.body2">@context.WorkDaysCount</MudText>
</MudTd>
<MudTd DataLabel="Status" Style="text-align:center">
@if (context.IsApproved)
{
<MudChip T="string" Color="Color.Success" Size="Size.Small" Icon="@Icons.Material.Filled.Check">Aktiv</MudChip>
}
else
{
<MudChip T="string" Color="Color.Warning" Size="Size.Small" Icon="@Icons.Material.Filled.HourglassEmpty">Ausstehend</MudChip>
}
</MudTd>
<MudTd Style="text-align:right">
@if (!context.IsApproved)
{
<MudButton Variant="Variant.Filled"
Color="Color.Success"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.CheckCircle"
OnClick="@(() => ApproveTenant(context))"
Class="mr-1">
Freigeben
</MudButton>
}
else
{
<MudButton Variant="Variant.Outlined"
Color="Color.Warning"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.Block"
OnClick="@(() => ToggleActiveTenant(context))"
Class="mr-1">
Sperren
</MudButton>
}
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
Size="Size.Small"
OnClick="@(() => DeleteTenant(context))" />
</MudTd>
</RowTemplate>
<ChildRowContent>
@if (context.ShowDetails)
{
<MudTr>
<td colspan="9" style="background-color: var(--mud-palette-background-grey); padding: 16px;">
<MudPaper Elevation="1" Class="pa-4 rounded-lg">
<MudText Typo="Typo.subtitle2" Class="mb-3" Style="font-weight: 700;">
Benutzer für Firma: @context.Name
</MudText>
@if (context.Users == null || context.Users.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">Keine Benutzer in dieser Firma registriert.</MudText>
}
else
{
<MudGrid Spacing="2">
@foreach (var user in context.Users)
{
<MudItem xs="12" sm="6" md="4">
<MudPaper Outlined="true" Class="pa-3 rounded-lg d-flex align-center justify-space-between" Style="background: white;">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudAvatar Color="user.IsTenantAdmin ? Color.Primary : Color.Default" Size="Size.Small">
@user.Username.Substring(0, Math.Min(2, user.Username.Length)).ToUpper()
</MudAvatar>
<MudStack Spacing="0">
<MudText Typo="Typo.body2" Style="font-weight: 600;">@user.Username</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">ID: @user.Id</MudText>
</MudStack>
</MudStack>
@if (user.IsTenantAdmin)
{
<MudChip T="string" Color="Color.Primary" Size="Size.Small" Variant="Variant.Text" Icon="@Icons.Material.Filled.AdminPanelSettings">Admin</MudChip>
}
else
{
<MudChip T="string" Color="Color.Default" Size="Size.Small" Variant="Variant.Text" Icon="@Icons.Material.Filled.Person">Mitarbeiter</MudChip>
}
</MudPaper>
</MudItem>
}
</MudGrid>
}
</MudPaper>
</td>
</MudTr>
}
</ChildRowContent>
</MudTable>
</MudCardContent>
</MudCard>
</MudStack>
}
@code {
[PersistentState(AllowUpdates = true)]
public List<TenantStats>? PersistentTenants { get; set; }
private bool _loading = true;
private List<TenantStats> _tenants = [];
protected override async Task OnInitializedAsync()
{
if (PersistentTenants != null)
{
_tenants = PersistentTenants;
_loading = false;
}
else
{
await LoadTenants();
}
}
private async Task LoadTenants()
{
_loading = true;
try
{
_tenants = await Http.GetFromJsonAsync<List<TenantStats>>("api/superadmin/tenants") ?? [];
PersistentTenants = _tenants;
}
catch (Exception ex)
{
Snackbar.Add($"Fehler beim Laden der Firmen: {ex.Message}", Severity.Error);
}
finally
{
_loading = false;
}
}
private async Task ApproveTenant(TenantStats tenant)
{
try
{
var response = await Http.PostAsync($"api/superadmin/tenants/{tenant.Id}/approve", null);
if (response.IsSuccessStatusCode)
{
tenant.IsApproved = true;
Snackbar.Add($"Firma '{tenant.Name}' wurde erfolgreich freigegeben.", Severity.Success);
}
else
{
Snackbar.Add("Fehler bei der Freigabe.", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"Fehler: {ex.Message}", Severity.Error);
}
}
private async Task ToggleActiveTenant(TenantStats tenant)
{
try
{
var response = await Http.PostAsync($"api/superadmin/tenants/{tenant.Id}/toggle-active", null);
if (response.IsSuccessStatusCode)
{
tenant.IsApproved = !tenant.IsApproved;
var stateMsg = tenant.IsApproved ? "aktiviert" : "gesperrt";
Snackbar.Add($"Firma '{tenant.Name}' wurde {stateMsg}.", Severity.Info);
}
else
{
Snackbar.Add("Fehler beim Ändern des Status.", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"Fehler: {ex.Message}", Severity.Error);
}
}
private async Task DeleteTenant(TenantStats tenant)
{
bool? result = await DialogService.ShowMessageBoxAsync(
"Firma unwiderruflich löschen?",
$"Sind Sie sicher, dass Sie die Firma '{tenant.Name}' ({tenant.Subdomain}) inklusive ALLER zugehörigen Benutzer, Einstellungen und Zeiterfassungen unwiderruflich löschen möchten? Dieser Vorgang kann nicht rückgängig gemacht werden!",
yesText: "Löschen", cancelText: "Abbrechen");
if (result == true)
{
try
{
var response = await Http.DeleteAsync($"api/superadmin/tenants/{tenant.Id}");
if (response.IsSuccessStatusCode)
{
_tenants.Remove(tenant);
Snackbar.Add($"Firma '{tenant.Name}' wurde gelöscht.", Severity.Success);
}
else
{
var err = await response.Content.ReadAsStringAsync();
Snackbar.Add($"Fehler beim Löschen: {err}", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"Fehler: {ex.Message}", Severity.Error);
}
}
}
}
@@ -1,17 +0,0 @@
@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
-27
View File
@@ -1,27 +0,0 @@
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();
@@ -1,25 +0,0 @@
{
"$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"
}
}
}
}
@@ -1,118 +0,0 @@
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, string? honeypot = null)
{
var response = await _http.PostAsJsonAsync("api/auth/register", new { Username = username, Password = password, Honeypot = honeypot });
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;
}
public async Task<string?> ChangePasswordAsync(int userId, string currentPassword, string newPassword)
{
var response = await _http.PutAsJsonAsync($"api/users/{userId}/change-password", new { CurrentPassword = currentPassword, NewPassword = newPassword });
if (!response.IsSuccessStatusCode)
{
return await response.Content.ReadAsStringAsync();
}
return null;
}
public async Task<(Tenant? Tenant, string? Error)> RegisterFirmAsync(RegisterFirmRequest req)
{
var response = await _http.PostAsJsonAsync("api/auth/register-firm", req);
if (response.IsSuccessStatusCode)
{
return (new Tenant { Name = req.CompanyName, Subdomain = req.Subdomain }, null);
}
else
{
var error = await response.Content.ReadAsStringAsync();
return (null, error);
}
}
public async Task<string?> AssignTenantAsync(int userId, int? tenantId)
{
var response = await _http.PutAsJsonAsync($"api/users/{userId}/assign-tenant", new AssignTenantRequest(tenantId));
if (!response.IsSuccessStatusCode)
{
return await response.Content.ReadAsStringAsync();
}
return null;
}
public async Task<string?> ResetPasswordAsync(int userId, string newPassword)
{
var response = await _http.PutAsJsonAsync($"api/users/{userId}/reset-password", new ResetPasswordRequest(newPassword));
if (!response.IsSuccessStatusCode)
{
return await response.Content.ReadAsStringAsync();
}
return null;
}
}
@@ -1,67 +0,0 @@
using System.Net.Http.Json;
using timetracker.Shared;
namespace timetracker.Client.Services;
public class ClientHolidayService : IHolidayService
{
private readonly HttpClient _http;
private readonly Dictionary<(int Year, string StateCode), List<PublicHoliday>> _holidayCache = new();
public ClientHolidayService(HttpClient http)
{
_http = http;
}
public async Task<List<PublicHoliday>> GetHolidaysAsync(int year, string? stateCode = null)
{
var sc = stateCode ?? "";
var key = (year, sc);
if (_holidayCache.TryGetValue(key, out var cached))
{
return cached;
}
var url = $"api/holidays?year={year}";
if (!string.IsNullOrEmpty(stateCode))
{
url += $"&stateCode={stateCode}";
}
var result = await _http.GetFromJsonAsync<List<PublicHoliday>>(url) ?? [];
_holidayCache[key] = result;
return result;
}
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)
{
// Invalidate the cache for this year
var keysToRemove = _holidayCache.Keys.Where(k => k.Year == year).ToList();
foreach (var k in keysToRemove)
{
_holidayCache.Remove(k);
}
return (result.Success, result.Message);
}
}
return (false, "Fehler beim Abrufen der Feiertage.");
}
public async Task DeleteAsync(int id)
{
await _http.DeleteAsync($"api/holidays/{id}");
// Invalidate all caches since we deleted a holiday
_holidayCache.Clear();
}
private class FetchResponse
{
public bool Success { get; set; }
public string Message { get; set; } = "";
}
}
@@ -1,83 +0,0 @@
using System.Net.Http.Json;
using timetracker.Shared;
namespace timetracker.Client.Services;
public class ClientTimetrackerService : ITimetrackerService
{
private readonly HttpClient _http;
private AppSettings? _cachedSettings;
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?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)
{
if (_cachedSettings != null && _cachedSettings.UserId == userId)
{
return _cachedSettings;
}
var settings = await _http.GetFromJsonAsync<AppSettings>("api/tracker/settings");
_cachedSettings = settings ?? new AppSettings { UserId = userId };
return _cachedSettings;
}
public async Task SaveSettingsAsync(AppSettings settings)
{
await _http.PostAsJsonAsync("api/tracker/settings", settings);
_cachedSettings = settings;
}
public async Task<List<VacationDay>> GetVacationDaysAsync(int userId, int year)
{
return await _http.GetFromJsonAsync<List<VacationDay>>($"api/tracker/vacation/{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/{id}");
}
public async Task<TimeSpan> GetTotalOvertimeAsync(int userId, AppSettings settings)
{
var response = await _http.PostAsJsonAsync("api/tracker/overtime", 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?year={year}&month={month}") ?? [];
}
public async Task<DashboardStatsVm> GetDashboardStatsAsync(int userId, int year, int month)
{
return await _http.GetFromJsonAsync<DashboardStatsVm>($"api/tracker/dashboard/stats?year={year}&month={month}") ?? new DashboardStatsVm();
}
public async Task<DashboardChartDataVm> GetDashboardChartDataAsync(int userId, int year)
{
return await _http.GetFromJsonAsync<DashboardChartDataVm>($"api/tracker/dashboard/chart?year={year}") ?? new DashboardChartDataVm();
}
}
@@ -1,100 +0,0 @@
using System.Security.Claims;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using timetracker.Shared;
namespace timetracker.Client.Services;
public class HostAuthenticationStateProvider : AuthenticationStateProvider
{
private readonly HttpClient _http;
private readonly PersistentComponentState _state;
private static readonly ClaimsPrincipal Anonymous = new(new ClaimsIdentity());
private ClaimsPrincipal? _currentUser;
public HostAuthenticationStateProvider(HttpClient http, PersistentComponentState state)
{
_http = http;
_state = state;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
if (_currentUser != null)
{
return new AuthenticationState(_currentUser);
}
if (_state.TryTakeFromJson<UserInfo>("UserInfoState", out var userInfo) && userInfo != null)
{
if (!userInfo.IsAuthenticated)
{
_currentUser = Anonymous;
return new AuthenticationState(_currentUser);
}
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, userInfo.Id.ToString()),
new Claim(ClaimTypes.Name, userInfo.Username),
new Claim("IsTenantAdmin", userInfo.IsTenantAdmin ? "true" : "false"),
new Claim(ClaimTypes.Role, userInfo.IsTenantAdmin ? "TenantAdmin" : "Employee")
};
var identity = new ClaimsIdentity(claims, "Cookie");
_currentUser = new ClaimsPrincipal(identity);
return new AuthenticationState(_currentUser);
}
try
{
var response = await _http.GetAsync("api/auth/me");
if (response.IsSuccessStatusCode)
{
var apiUserInfo = await response.Content.ReadFromJsonAsync<UserInfo>();
if (apiUserInfo != null)
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, apiUserInfo.Id.ToString()),
new Claim(ClaimTypes.Name, apiUserInfo.Username),
new Claim("IsTenantAdmin", apiUserInfo.IsTenantAdmin ? "true" : "false"),
new Claim(ClaimTypes.Role, apiUserInfo.IsTenantAdmin ? "TenantAdmin" : "Employee")
};
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),
new Claim("IsTenantAdmin", userInfo.IsTenantAdmin ? "true" : "false"),
new Claim(ClaimTypes.Role, userInfo.IsTenantAdmin ? "TenantAdmin" : "Employee")
};
var identity = new ClaimsIdentity(claims, "Cookie");
_currentUser = new ClaimsPrincipal(identity);
}
else
{
_currentUser = Anonymous;
}
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(_currentUser)));
}
}
@@ -1,56 +0,0 @@
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();
}
}
}
-13
View File
@@ -1,13 +0,0 @@
@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
@@ -1,22 +0,0 @@
<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.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

-54
View File
@@ -1,54 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<ResourcePreloader />
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
<link rel="stylesheet" href="_content/MudBlazor/MudBlazor.min.css" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["timetracker.Client.styles.css"]" />
<ImportMap />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="alternate icon" type="image/png" href="favicon.png" />
<style>
.onboarding-active-target {
position: relative !important;
z-index: 10000 !important;
box-shadow: 0 0 0 9999px rgba(15, 23, 42, 0.75), 0 0 15px rgba(14, 165, 233, 0.5) !important;
pointer-events: none !important;
transition: all 0.3s ease !important;
}
</style>
<HeadOutlet @rendermode="InteractiveWebAssembly" />
</head>
<body>
<Routes />
<persist-component-state />
<script src="@Assets["_framework/blazor.web.js"]"></script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
<script>
window.onboarding = {
highlight: function (selector) {
document.querySelectorAll('.onboarding-active-target').forEach(function(el) {
el.classList.remove('onboarding-active-target');
});
var target = document.querySelector(selector);
if (target) {
target.classList.add('onboarding-active-target');
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
},
clear: function () {
document.querySelectorAll('.onboarding-active-target').forEach(function(el) {
el.classList.remove('onboarding-active-target');
});
}
};
</script>
</body>
</html>
-287
View File
@@ -1,287 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using timetracker.Shared;
namespace timetracker.Data;
public class AuthService(
IDbContextFactory<TimetrackerDbContext> factory,
UserNotificationService notifier,
ITenantProvider tenantProvider) : IAuthService
{
private const string ReservedSuperAdminUsername = "marc";
public async Task<User?> LoginAsync(string username, string password)
{
await using var db = await factory.CreateDbContextAsync();
// Global query filter automatically restricts lookup to current tenant context
var user = await db.Users
.FirstOrDefaultAsync(u => u.Username == username);
if (user == null) return null;
return VerifyPassword(password, user.PasswordHash, user.PasswordSalt) ? user : null;
}
public async Task<List<User>> GetAllUsersAsync()
{
await using var db = await factory.CreateDbContextAsync();
// Superadmin bypasses query filters to manage all users globally
return await db.Users
.IgnoreQueryFilters()
.OrderBy(u => u.Username)
.ToListAsync();
}
public async Task DeleteUserAsync(int userId)
{
await using var db = await factory.CreateDbContextAsync();
var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId);
if (user != null)
{
db.Users.Remove(user);
await db.SaveChangesAsync();
await notifier.NotifyUserDeletedAsync(userId);
await notifier.NotifyUsersChangedAsync();
}
}
public async Task<string?> RenameUserAsync(int userId, string newUsername)
{
if (string.IsNullOrWhiteSpace(newUsername) || newUsername.Length < 3)
return "Benutzername muss mindestens 3 Zeichen lang sein.";
await using var db = await factory.CreateDbContextAsync();
// Find user ignoring filters so we can rename global admin too if needed
var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId);
if (user == null) return "Benutzer nicht gefunden.";
if (!CanUseReservedUsername(user.TenantId, newUsername, allowExistingReservedUser: user.TenantId == null && user.Username.Equals(ReservedSuperAdminUsername, StringComparison.OrdinalIgnoreCase)))
return "Dieser Benutzername ist reserviert.";
// Check if name is taken within the same tenant context
var isTaken = await db.Users.AnyAsync(u => u.Username == newUsername && u.Id != userId && u.TenantId == user.TenantId);
if (isTaken)
return "Benutzername in dieser Firma bereits vergeben.";
user.Username = newUsername;
await db.SaveChangesAsync();
await notifier.NotifyUsersChangedAsync();
return null;
}
public async Task<string?> ChangePasswordAsync(int userId, string currentPassword, string newPassword)
{
if (string.IsNullOrWhiteSpace(newPassword) || newPassword.Length < 6)
return "Passwort muss mindestens 6 Zeichen lang sein.";
await using var db = await factory.CreateDbContextAsync();
var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId);
if (user == null) return "Benutzer nicht gefunden.";
if (!VerifyPassword(currentPassword, user.PasswordHash, user.PasswordSalt))
return "Das aktuelle Passwort ist nicht korrekt.";
var (hash, salt) = HashPassword(newPassword);
user.PasswordHash = hash;
user.PasswordSalt = salt;
await db.SaveChangesAsync();
return null;
}
public async Task<(User? User, string? Error)> RegisterAsync(string username, string password, string? honeypot = null)
{
if (string.IsNullOrWhiteSpace(username) || username.Length < 3)
return (null, "Benutzername muss mindestens 3 Zeichen lang sein.");
if (string.IsNullOrWhiteSpace(password) || password.Length < 6)
return (null, "Passwort muss mindestens 6 Zeichen lang sein.");
var tenantId = tenantProvider.TenantId;
if (!CanUseReservedUsername(tenantId, username))
{
return (null, "Dieser Benutzername ist reserviert.");
}
await using var db = await factory.CreateDbContextAsync();
// Filter handles checking uniqueness within the same tenant context
if (await db.Users.AnyAsync(u => u.Username == username))
return (null, tenantId.HasValue ? "Benutzername in dieser Firma bereits vergeben." : "Benutzername bereits vergeben.");
var (hash, salt) = HashPassword(password);
var user = new User
{
Username = username,
PasswordHash = hash,
PasswordSalt = salt,
TenantId = tenantId
};
db.Users.Add(user);
await db.SaveChangesAsync();
await notifier.NotifyUsersChangedAsync();
return (user, null);
}
public async Task<(Tenant? Tenant, string? Error)> RegisterFirmAsync(RegisterFirmRequest req)
{
if (string.IsNullOrWhiteSpace(req.CompanyName) || req.CompanyName.Length < 3)
return (null, "Firmenname muss mindestens 3 Zeichen lang sein.");
if (string.IsNullOrWhiteSpace(req.Subdomain) || req.Subdomain.Length < 3)
return (null, "Subdomain muss mindestens 3 Zeichen lang sein.");
if (string.IsNullOrWhiteSpace(req.AdminUsername) || req.AdminUsername.Length < 3)
return (null, "Administrator-Benutzername muss mindestens 3 Zeichen lang sein.");
if (string.IsNullOrWhiteSpace(req.AdminPassword) || req.AdminPassword.Length < 6)
return (null, "Passwort muss mindestens 6 Zeichen lang sein.");
if (!CanUseReservedUsername(tenantId: 0, req.AdminUsername))
return (null, "Dieser Benutzername ist reserviert.");
var subdomain = req.Subdomain.ToLower().Trim();
if (!subdomain.All(c => char.IsLetterOrDigit(c) || c == '-'))
{
return (null, "Subdomain darf nur Buchstaben, Zahlen und Bindestriche enthalten.");
}
var reservedSubdomains = new[] { "www", "api", "admin", "superadmin", "localhost", "mail", "portal", "app", "dev", "test" };
if (reservedSubdomains.Contains(subdomain))
{
return (null, "Diese Subdomain ist reserviert und kann nicht verwendet werden.");
}
await using var db = await factory.CreateDbContextAsync();
// Tenant checks are global, no filters applied to Tenants table
var exists = await db.Tenants.AnyAsync(t => t.Subdomain == subdomain);
if (exists)
{
return (null, "Diese Subdomain ist bereits vergeben.");
}
await using var transaction = await db.Database.BeginTransactionAsync();
try
{
var tenant = new Tenant
{
Name = req.CompanyName,
Subdomain = subdomain,
IsApproved = false,
CreatedAt = DateTime.UtcNow
};
db.Tenants.Add(tenant);
await db.SaveChangesAsync();
var (hash, salt) = HashPassword(req.AdminPassword);
var user = new User
{
Username = req.AdminUsername,
PasswordHash = hash,
PasswordSalt = salt,
TenantId = tenant.Id,
IsTenantAdmin = true
};
db.Users.Add(user);
await db.SaveChangesAsync();
await transaction.CommitAsync();
await notifier.NotifyUsersChangedAsync();
return (tenant, null);
}
catch (Exception ex)
{
await transaction.RollbackAsync();
return (null, $"Registrierungsfehler: {ex.Message}");
}
}
public async Task<string?> AssignTenantAsync(int userId, int? tenantId)
{
await using var db = await factory.CreateDbContextAsync();
var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId);
if (user == null) return "Benutzer nicht gefunden.";
if (tenantId.HasValue)
{
var tenantExists = await db.Tenants.AnyAsync(t => t.Id == tenantId.Value);
if (!tenantExists) return "Firma existiert nicht.";
}
await using var transaction = await db.Database.BeginTransactionAsync();
try
{
user.TenantId = tenantId;
var settings = await db.AppSettings.IgnoreQueryFilters().Where(s => s.UserId == userId).ToListAsync();
foreach (var s in settings) s.TenantId = tenantId;
var workDays = await db.WorkDays.IgnoreQueryFilters().Where(w => w.UserId == userId).ToListAsync();
foreach (var w in workDays) w.TenantId = tenantId;
var vacationDays = await db.VacationDays.IgnoreQueryFilters().Where(v => v.UserId == userId).ToListAsync();
foreach (var v in vacationDays) v.TenantId = tenantId;
await db.SaveChangesAsync();
await transaction.CommitAsync();
await notifier.NotifyUsersChangedAsync();
return null;
}
catch (Exception ex)
{
await transaction.RollbackAsync();
return $"Fehler beim Verschieben des Benutzers: {ex.Message}";
}
}
public async Task<string?> ResetPasswordAsync(int userId, string newPassword)
{
if (string.IsNullOrWhiteSpace(newPassword) || newPassword.Length < 6)
return "Passwort muss mindestens 6 Zeichen lang sein.";
await using var db = await factory.CreateDbContextAsync();
var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId);
if (user == null) return "Benutzer nicht gefunden.";
var (hash, salt) = HashPassword(newPassword);
user.PasswordHash = hash;
user.PasswordSalt = salt;
await db.SaveChangesAsync();
return null;
}
private static (string hash, string salt) HashPassword(string password)
{
var saltBytes = RandomNumberGenerator.GetBytes(32);
var salt = Convert.ToBase64String(saltBytes);
var hash = ComputeHash(password, salt);
return (hash, salt);
}
private static bool VerifyPassword(string password, string hash, string salt)
=> ComputeHash(password, salt) == hash;
private static bool CanUseReservedUsername(int? tenantId, string username, bool allowExistingReservedUser = false)
{
if (!username.Equals(ReservedSuperAdminUsername, StringComparison.OrdinalIgnoreCase))
return true;
return allowExistingReservedUser || tenantId == null;
}
private static string ComputeHash(string password, string salt)
{
var hash = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(password),
Convert.FromBase64String(salt),
iterations: 200_000,
HashAlgorithmName.SHA256,
outputLength: 32);
return Convert.ToBase64String(hash);
}
}
-278
View File
@@ -1,278 +0,0 @@
using ClosedXML.Excel;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using timetracker.Shared;
namespace timetracker.Data;
public static class ExcelExporter
{
public static async Task<byte[]> GenerateExcelExportAsync(
int userId,
DateOnly startDate,
DateOnly endDate,
string title,
ITimetrackerService trackerService,
TimetrackerDbContext db)
{
var settings = await trackerService.GetSettingsAsync(userId);
var workDays = await db.WorkDays
.Include(w => w.Breaks)
.Where(w => w.UserId == userId && w.Date >= startDate && w.Date <= endDate)
.ToDictionaryAsync(w => w.Date);
var holidaysList = await db.PublicHolidays
.Where(h => h.Date >= startDate && h.Date <= endDate)
.ToListAsync();
var state = settings.GermanState;
var holidayMap = holidaysList
.Where(h => string.IsNullOrEmpty(h.Counties) || (!string.IsNullOrEmpty(state) && h.Counties.Split(',').Contains(state)))
.ToDictionary(h => h.Date, h => h.Name);
var vacationMap = await db.VacationDays
.Where(v => v.UserId == userId && v.Date >= startDate && v.Date <= endDate)
.ToDictionaryAsync(v => v.Date, v => v.Note ?? "Urlaub");
var user = await db.Users.FindAsync(userId);
var userName = user != null ? user.Username : "Mitarbeiter";
var rows = new List<ExportRow>();
for (var date = startDate; date <= endDate; date = date.AddDays(1))
{
bool isWorkDay = settings.IsWorkDay(date.DayOfWeek);
bool isHoliday = holidayMap.ContainsKey(date);
bool isVacation = vacationMap.ContainsKey(date);
double targetHours = (isWorkDay && !isHoliday && !isVacation) ? settings.DailyTargetHours : 0.0;
string status = "Wochenende";
if (isHoliday) status = "Feiertag";
else if (isVacation) status = "Urlaub";
else if (isWorkDay) status = "Arbeitstag";
TimeSpan? startTime = null;
TimeSpan? endTime = null;
TimeSpan breakTime = TimeSpan.Zero;
double actualHours = 0.0;
string note = "";
if (isHoliday) note = holidayMap[date];
else if (isVacation) note = vacationMap[date];
if (workDays.TryGetValue(date, out var wd))
{
if (wd.StartTime.HasValue) startTime = wd.StartTime.Value.ToTimeSpan();
if (wd.EndTime.HasValue) endTime = wd.EndTime.Value.ToTimeSpan();
if (startTime.HasValue && endTime.HasValue)
{
var gross = endTime.Value - startTime.Value;
if (gross > TimeSpan.Zero)
{
var breakTotal = 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()));
var minMinutes = gross.TotalHours >= 9.0 ? 45 : 30;
var minBreak = TimeSpan.FromMinutes(Math.Max(minMinutes, settings.MinimumBreakMinutes));
var finalBreak = breakTotal > minBreak ? breakTotal : minBreak;
breakTime = finalBreak;
actualHours = (gross - finalBreak).TotalHours;
if (actualHours < 0) actualHours = 0.0;
}
}
}
rows.Add(new ExportRow
{
Date = date,
Status = status,
StartTime = startTime,
EndTime = endTime,
BreakTime = breakTime,
ActualHours = actualHours,
TargetHours = targetHours,
DiffHours = actualHours - targetHours,
Note = note
});
}
return ExportToExcel(title, userName, startDate, endDate, rows);
}
public static byte[] ExportToExcel(
string title,
string userName,
DateOnly startDate,
DateOnly endDate,
List<ExportRow> rows)
{
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Arbeitszeiten");
// Gitterlinien explizit einblenden
worksheet.ShowGridLines = true;
// Title Block
worksheet.Cell("A1").Value = title;
worksheet.Cell("A1").Style.Font.Bold = true;
worksheet.Cell("A1").Style.Font.FontSize = 16;
worksheet.Cell("A1").Style.Font.FontColor = XLColor.FromHtml("#0F172A"); // Slate 900
// Info Block
worksheet.Cell("A2").Value = $"Mitarbeiter: {userName}";
worksheet.Cell("A2").Style.Font.FontSize = 11;
worksheet.Cell("A2").Style.Font.Italic = true;
worksheet.Cell("A3").Value = $"Zeitraum: {startDate:dd.MM.yyyy} bis {endDate:dd.MM.yyyy}";
worksheet.Cell("A3").Style.Font.FontSize = 11;
worksheet.Cell("A3").Style.Font.Italic = true;
// Tabellenüberschriften (Zeile 5)
string[] headers = {
"Datum", "Wochentag", "Status", "Beginn", "Ende",
"Pause", "Netto (Ist)", "Soll-Stunden", "Differenz (Gleitzeit)", "Notiz / Feiertag / Urlaub"
};
for (int i = 0; i < headers.Length; i++)
{
var cell = worksheet.Cell(5, i + 1);
cell.Value = headers[i];
cell.Style.Font.Bold = true;
cell.Style.Font.FontColor = XLColor.White;
cell.Style.Fill.BackgroundColor = XLColor.FromHtml("#0EA5E9"); // Primary color (Sky 500)
cell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
}
// Datenzeilen
int currentRow = 6;
var deCulture = new CultureInfo("de-DE");
foreach (var r in rows)
{
worksheet.Cell(currentRow, 1).Value = r.Date.ToString("dd.MM.yyyy");
worksheet.Cell(currentRow, 2).Value = r.Date.ToString("dddd", deCulture);
worksheet.Cell(currentRow, 3).Value = r.Status;
if (r.StartTime.HasValue)
{
worksheet.Cell(currentRow, 4).Value = r.StartTime.Value.ToString(@"hh\:mm");
}
else
{
worksheet.Cell(currentRow, 4).Value = "—";
}
if (r.EndTime.HasValue)
{
worksheet.Cell(currentRow, 5).Value = r.EndTime.Value.ToString(@"hh\:mm");
}
else
{
worksheet.Cell(currentRow, 5).Value = "—";
}
worksheet.Cell(currentRow, 6).Value = r.BreakTime.TotalMinutes > 0 ? r.BreakTime.ToString(@"hh\:mm") : "—";
worksheet.Cell(currentRow, 7).Value = r.ActualHours;
worksheet.Cell(currentRow, 7).Style.NumberFormat.Format = "0.00";
worksheet.Cell(currentRow, 8).Value = r.TargetHours;
worksheet.Cell(currentRow, 8).Style.NumberFormat.Format = "0.00";
worksheet.Cell(currentRow, 9).Value = r.DiffHours;
worksheet.Cell(currentRow, 9).Style.NumberFormat.Format = "+0.00;-0.00;0.00";
if (r.DiffHours > 0)
{
worksheet.Cell(currentRow, 9).Style.Font.FontColor = XLColor.FromHtml("#16A34A"); // Green 600
worksheet.Cell(currentRow, 9).Style.Font.Bold = true;
}
else if (r.DiffHours < 0)
{
worksheet.Cell(currentRow, 9).Style.Font.FontColor = XLColor.FromHtml("#DC2626"); // Red 600
worksheet.Cell(currentRow, 9).Style.Font.Bold = true;
}
worksheet.Cell(currentRow, 10).Value = r.Note;
// Zebra-Streifen
if (currentRow % 2 == 0)
{
for (int col = 1; col <= headers.Length; col++)
{
worksheet.Cell(currentRow, col).Style.Fill.BackgroundColor = XLColor.FromHtml("#F8FAFC"); // Slate 50
}
}
// Rahmenlinien
for (int col = 1; col <= headers.Length; col++)
{
worksheet.Cell(currentRow, col).Style.Border.BottomBorder = XLBorderStyleValues.Thin;
worksheet.Cell(currentRow, col).Style.Border.BottomBorderColor = XLColor.FromHtml("#E2E8F0");
}
currentRow++;
}
// Summenzeile
currentRow++;
worksheet.Cell(currentRow, 6).Value = "Gesamt:";
worksheet.Cell(currentRow, 6).Style.Font.Bold = true;
worksheet.Cell(currentRow, 6).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Right;
var actualSumRange = $"G6:G{currentRow - 2}";
worksheet.Cell(currentRow, 7).FormulaA1 = $"SUM({actualSumRange})";
worksheet.Cell(currentRow, 7).Style.Font.Bold = true;
worksheet.Cell(currentRow, 7).Style.NumberFormat.Format = "0.00";
var targetSumRange = $"H6:H{currentRow - 2}";
worksheet.Cell(currentRow, 8).FormulaA1 = $"SUM({targetSumRange})";
worksheet.Cell(currentRow, 8).Style.Font.Bold = true;
worksheet.Cell(currentRow, 8).Style.NumberFormat.Format = "0.00";
var diffSumRange = $"I6:I{currentRow - 2}";
worksheet.Cell(currentRow, 9).FormulaA1 = $"SUM({diffSumRange})";
worksheet.Cell(currentRow, 9).Style.Font.Bold = true;
worksheet.Cell(currentRow, 9).Style.NumberFormat.Format = "+0.00;-0.00;0.00";
// Summenrahmen
for (int col = 6; col <= 9; col++)
{
worksheet.Cell(currentRow, col).Style.Border.TopBorder = XLBorderStyleValues.Thin;
worksheet.Cell(currentRow, col).Style.Border.TopBorderColor = XLColor.Black;
worksheet.Cell(currentRow, col).Style.Border.BottomBorder = XLBorderStyleValues.Double;
worksheet.Cell(currentRow, col).Style.Border.BottomBorderColor = XLColor.Black;
}
// Spaltenbreiten automatisch anpassen
worksheet.Columns().AdjustToContents();
using var memoryStream = new MemoryStream();
workbook.SaveAs(memoryStream);
return memoryStream.ToArray();
}
}
public class ExportRow
{
public DateOnly Date { get; set; }
public string Status { get; set; } = "";
public TimeSpan? StartTime { get; set; }
public TimeSpan? EndTime { get; set; }
public TimeSpan BreakTime { get; set; }
public double ActualHours { get; set; }
public double TargetHours { get; set; }
public double DiffHours { get; set; }
public string Note { get; set; } = "";
}
@@ -1,10 +0,0 @@
using System.Threading.Tasks;
using timetracker.Shared;
namespace timetracker.Data;
public interface ITenantProvider
{
int? TenantId { get; }
Task<Tenant?> GetCurrentTenantAsync();
}
@@ -1,104 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace timetracker.Data.Migrations
{
/// <inheritdoc />
public partial class AddMultiTenancy : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "TenantId",
table: "WorkDays",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "TenantId",
table: "VacationDays",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "TenantId",
table: "Users",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "TenantId",
table: "AppSettings",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateTable(
name: "Tenants",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: false),
Subdomain = table.Column<string>(type: "TEXT", nullable: false),
IsApproved = table.Column<bool>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tenants", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Users_TenantId",
table: "Users",
column: "TenantId");
migrationBuilder.CreateIndex(
name: "IX_Tenants_Subdomain",
table: "Tenants",
column: "Subdomain",
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_Users_Tenants_TenantId",
table: "Users",
column: "TenantId",
principalTable: "Tenants",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Users_Tenants_TenantId",
table: "Users");
migrationBuilder.DropTable(
name: "Tenants");
migrationBuilder.DropIndex(
name: "IX_Users_TenantId",
table: "Users");
migrationBuilder.DropColumn(
name: "TenantId",
table: "WorkDays");
migrationBuilder.DropColumn(
name: "TenantId",
table: "VacationDays");
migrationBuilder.DropColumn(
name: "TenantId",
table: "Users");
migrationBuilder.DropColumn(
name: "TenantId",
table: "AppSettings");
}
}
}
@@ -1,265 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using timetracker.Data;
#nullable disable
namespace timetracker.Data.Migrations
{
[DbContext(typeof(TimetrackerDbContext))]
[Migration("20260624210436_AddTenantBranding")]
partial class AddTenantBranding
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
modelBuilder.Entity("timetracker.Shared.AppSettings", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<double>("DailyTargetHours")
.HasColumnType("REAL");
b.Property<DateOnly?>("FlexTimeStartDate")
.HasColumnType("TEXT");
b.Property<double>("FlexTimeStartingBalanceHours")
.HasColumnType("REAL");
b.Property<string>("GermanState")
.HasColumnType("TEXT");
b.Property<int>("MinimumBreakMinutes")
.HasColumnType("INTEGER");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<int>("VacationDaysPerYear")
.HasColumnType("INTEGER");
b.Property<bool>("WorkFriday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkMonday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkSaturday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkSunday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkThursday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkTuesday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkWednesday")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("AppSettings");
});
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<TimeOnly?>("EndTime")
.HasColumnType("TEXT");
b.Property<TimeOnly?>("StartTime")
.HasColumnType("TEXT");
b.Property<int>("WorkDayId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("WorkDayId");
b.ToTable("BreakEntries");
});
modelBuilder.Entity("timetracker.Shared.PublicHoliday", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Counties")
.HasColumnType("TEXT");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("PublicHolidays");
});
modelBuilder.Entity("timetracker.Shared.Tenant", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsApproved")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PrimaryColor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SecondaryColor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Subdomain")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Subdomain")
.IsUnique();
b.ToTable("Tenants");
});
modelBuilder.Entity("timetracker.Shared.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("IsTenantAdmin")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PasswordSalt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TenantId");
b.ToTable("Users");
});
modelBuilder.Entity("timetracker.Shared.VacationDay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("VacationDays");
});
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<TimeOnly?>("EndTime")
.HasColumnType("TEXT");
b.Property<TimeOnly?>("StartTime")
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("WorkDays");
});
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
{
b.HasOne("timetracker.Shared.WorkDay", "WorkDay")
.WithMany("Breaks")
.HasForeignKey("WorkDayId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("WorkDay");
});
modelBuilder.Entity("timetracker.Shared.User", b =>
{
b.HasOne("timetracker.Shared.Tenant", "Tenant")
.WithMany()
.HasForeignKey("TenantId");
b.Navigation("Tenant");
});
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
{
b.Navigation("Breaks");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,50 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace timetracker.Data.Migrations
{
/// <inheritdoc />
public partial class AddTenantBranding : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsTenantAdmin",
table: "Users",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "PrimaryColor",
table: "Tenants",
type: "TEXT",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "SecondaryColor",
table: "Tenants",
type: "TEXT",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsTenantAdmin",
table: "Users");
migrationBuilder.DropColumn(
name: "PrimaryColor",
table: "Tenants");
migrationBuilder.DropColumn(
name: "SecondaryColor",
table: "Tenants");
}
}
}
@@ -1,265 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using timetracker.Data;
#nullable disable
namespace timetracker.Data.Migrations
{
[DbContext(typeof(TimetrackerDbContext))]
[Migration("20260625184756_PendingChanges")]
partial class PendingChanges
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
modelBuilder.Entity("timetracker.Shared.AppSettings", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<double>("DailyTargetHours")
.HasColumnType("REAL");
b.Property<DateOnly?>("FlexTimeStartDate")
.HasColumnType("TEXT");
b.Property<double>("FlexTimeStartingBalanceHours")
.HasColumnType("REAL");
b.Property<string>("GermanState")
.HasColumnType("TEXT");
b.Property<int>("MinimumBreakMinutes")
.HasColumnType("INTEGER");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<int>("VacationDaysPerYear")
.HasColumnType("INTEGER");
b.Property<bool>("WorkFriday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkMonday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkSaturday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkSunday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkThursday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkTuesday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkWednesday")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("AppSettings");
});
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<TimeOnly?>("EndTime")
.HasColumnType("TEXT");
b.Property<TimeOnly?>("StartTime")
.HasColumnType("TEXT");
b.Property<int>("WorkDayId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("WorkDayId");
b.ToTable("BreakEntries");
});
modelBuilder.Entity("timetracker.Shared.PublicHoliday", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Counties")
.HasColumnType("TEXT");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("PublicHolidays");
});
modelBuilder.Entity("timetracker.Shared.Tenant", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsApproved")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PrimaryColor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SecondaryColor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Subdomain")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Subdomain")
.IsUnique();
b.ToTable("Tenants");
});
modelBuilder.Entity("timetracker.Shared.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("IsTenantAdmin")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PasswordSalt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TenantId");
b.ToTable("Users");
});
modelBuilder.Entity("timetracker.Shared.VacationDay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("VacationDays");
});
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<TimeOnly?>("EndTime")
.HasColumnType("TEXT");
b.Property<TimeOnly?>("StartTime")
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("WorkDays");
});
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
{
b.HasOne("timetracker.Shared.WorkDay", "WorkDay")
.WithMany("Breaks")
.HasForeignKey("WorkDayId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("WorkDay");
});
modelBuilder.Entity("timetracker.Shared.User", b =>
{
b.HasOne("timetracker.Shared.Tenant", "Tenant")
.WithMany()
.HasForeignKey("TenantId");
b.Navigation("Tenant");
});
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
{
b.Navigation("Breaks");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,22 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace timetracker.Data.Migrations
{
/// <inheritdoc />
public partial class PendingChanges : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -1,262 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using timetracker.Data;
#nullable disable
namespace timetracker.Data.Migrations
{
[DbContext(typeof(TimetrackerDbContext))]
partial class TimetrackerDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
modelBuilder.Entity("timetracker.Shared.AppSettings", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<double>("DailyTargetHours")
.HasColumnType("REAL");
b.Property<DateOnly?>("FlexTimeStartDate")
.HasColumnType("TEXT");
b.Property<double>("FlexTimeStartingBalanceHours")
.HasColumnType("REAL");
b.Property<string>("GermanState")
.HasColumnType("TEXT");
b.Property<int>("MinimumBreakMinutes")
.HasColumnType("INTEGER");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<int>("VacationDaysPerYear")
.HasColumnType("INTEGER");
b.Property<bool>("WorkFriday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkMonday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkSaturday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkSunday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkThursday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkTuesday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkWednesday")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("AppSettings");
});
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<TimeOnly?>("EndTime")
.HasColumnType("TEXT");
b.Property<TimeOnly?>("StartTime")
.HasColumnType("TEXT");
b.Property<int>("WorkDayId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("WorkDayId");
b.ToTable("BreakEntries");
});
modelBuilder.Entity("timetracker.Shared.PublicHoliday", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Counties")
.HasColumnType("TEXT");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("PublicHolidays");
});
modelBuilder.Entity("timetracker.Shared.Tenant", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsApproved")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PrimaryColor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SecondaryColor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Subdomain")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Subdomain")
.IsUnique();
b.ToTable("Tenants");
});
modelBuilder.Entity("timetracker.Shared.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("IsTenantAdmin")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PasswordSalt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TenantId");
b.ToTable("Users");
});
modelBuilder.Entity("timetracker.Shared.VacationDay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("VacationDays");
});
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<TimeOnly?>("EndTime")
.HasColumnType("TEXT");
b.Property<TimeOnly?>("StartTime")
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("WorkDays");
});
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
{
b.HasOne("timetracker.Shared.WorkDay", "WorkDay")
.WithMany("Breaks")
.HasForeignKey("WorkDayId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("WorkDay");
});
modelBuilder.Entity("timetracker.Shared.User", b =>
{
b.HasOne("timetracker.Shared.Tenant", "Tenant")
.WithMany()
.HasForeignKey("TenantId");
b.Navigation("Tenant");
});
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
{
b.Navigation("Breaks");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,7 +0,0 @@
using Microsoft.AspNetCore.SignalR;
namespace timetracker.Data;
public class NotificationHub : Hub
{
}
-126
View File
@@ -1,126 +0,0 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using timetracker.Shared;
namespace timetracker.Data;
public class TenantProvider : ITenantProvider
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IDbContextFactory<TimetrackerDbContext> _dbContextFactory;
private readonly IConfiguration _configuration;
public TenantProvider(
IHttpContextAccessor httpContextAccessor,
IDbContextFactory<TimetrackerDbContext> dbContextFactory,
IConfiguration configuration)
{
_httpContextAccessor = httpContextAccessor;
_dbContextFactory = dbContextFactory;
_configuration = configuration;
}
public int? TenantId
{
get
{
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext == null) return null;
if (httpContext.Items.TryGetValue("TenantId", out var cachedId))
{
return (int?)cachedId;
}
// Resolve and cache in HttpContext.Items
ResolveTenant();
if (httpContext.Items.TryGetValue("TenantId", out cachedId))
{
return (int?)cachedId;
}
return null;
}
}
public Task<Tenant?> GetCurrentTenantAsync()
{
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext == null) return Task.FromResult<Tenant?>(null);
if (httpContext.Items.TryGetValue("CurrentTenant", out var cachedTenant))
{
return Task.FromResult((Tenant?)cachedTenant);
}
ResolveTenant();
if (httpContext.Items.TryGetValue("CurrentTenant", out cachedTenant))
{
return Task.FromResult((Tenant?)cachedTenant);
}
return Task.FromResult<Tenant?>(null);
}
private void ResolveTenant()
{
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext == null) return;
// Prevent double resolution in the same request
if (httpContext.Items.ContainsKey("TenantResolved")) return;
httpContext.Items["TenantResolved"] = true;
var host = httpContext.Request.Host.Host;
var subdomain = GetSubdomain(host);
if (!string.IsNullOrEmpty(subdomain))
{
using var db = _dbContextFactory.CreateDbContext();
var tenant = db.Tenants
.FirstOrDefault(t => t.Subdomain.ToLower() == subdomain.ToLower());
if (tenant != null)
{
httpContext.Items["CurrentTenant"] = tenant;
httpContext.Items["TenantId"] = tenant.Id;
}
}
}
private string? GetSubdomain(string host)
{
var baseDomain = _configuration["TenantSettings:BaseDomain"] ?? "localhost";
// Ignore port if included in host
var hostName = host.Split(':')[0];
if (hostName.Equals(baseDomain, StringComparison.OrdinalIgnoreCase))
{
return null; // Main domain
}
if (hostName.EndsWith("." + baseDomain, StringComparison.OrdinalIgnoreCase))
{
return hostName.Substring(0, hostName.Length - baseDomain.Length - 1);
}
// Fallback for localhost testing
if (baseDomain.Equals("localhost", StringComparison.OrdinalIgnoreCase))
{
var parts = hostName.Split('.');
if (parts.Length > 1 && !hostName.Equals("localhost", StringComparison.OrdinalIgnoreCase))
{
return parts[0];
}
}
return null;
}
}
@@ -1,72 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using timetracker.Shared;
namespace timetracker.Data;
public class TimetrackerDbContext : DbContext
{
private readonly IServiceProvider _serviceProvider;
private ITenantProvider? _tenantProvider;
private ITenantProvider TenantProvider => _tenantProvider ??= _serviceProvider.GetRequiredService<ITenantProvider>();
public TimetrackerDbContext(
DbContextOptions<TimetrackerDbContext> options,
IServiceProvider serviceProvider) : base(options)
{
_serviceProvider = serviceProvider;
}
public DbSet<Tenant> Tenants => Set<Tenant>();
public DbSet<User> Users => Set<User>();
public DbSet<WorkDay> WorkDays => Set<WorkDay>();
public DbSet<BreakEntry> BreakEntries => Set<BreakEntry>();
public DbSet<AppSettings> AppSettings => Set<AppSettings>();
public DbSet<VacationDay> VacationDays => Set<VacationDay>();
public DbSet<PublicHoliday> PublicHolidays => Set<PublicHoliday>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Subdomain index unique
modelBuilder.Entity<Tenant>()
.HasIndex(t => t.Subdomain)
.IsUnique();
// Global Query Filters (scopes database views by resolved TenantId)
modelBuilder.Entity<User>().HasQueryFilter(u => u.TenantId == TenantProvider.TenantId);
modelBuilder.Entity<WorkDay>().HasQueryFilter(w => w.TenantId == TenantProvider.TenantId);
modelBuilder.Entity<VacationDay>().HasQueryFilter(v => v.TenantId == TenantProvider.TenantId);
modelBuilder.Entity<AppSettings>().HasQueryFilter(s => s.TenantId == TenantProvider.TenantId);
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var tenantId = TenantProvider.TenantId;
if (tenantId.HasValue)
{
foreach (var entry in ChangeTracker.Entries())
{
if (entry.State == EntityState.Added)
{
var tenantIdProp = entry.Entity.GetType().GetProperty("TenantId");
if (tenantIdProp != null && tenantIdProp.CanWrite)
{
var currentValue = tenantIdProp.GetValue(entry.Entity);
if (currentValue == null || (currentValue is int val && val == 0))
{
tenantIdProp.SetValue(entry.Entity, tenantId.Value);
}
}
}
}
}
return base.SaveChangesAsync(cancellationToken);
}
}
@@ -1,37 +0,0 @@
using Microsoft.AspNetCore.SignalR;
using timetracker.Shared;
namespace timetracker.Data;
public class UserNotificationService : IUserNotificationService
{
private readonly IHubContext<NotificationHub> _hubContext;
public event Func<Task>? OnUsersChanged;
public event Func<int, Task>? OnUserDeleted;
public UserNotificationService(IHubContext<NotificationHub> hubContext)
{
_hubContext = hubContext;
}
public async Task NotifyUsersChangedAsync()
{
// Broadcast via SignalR to all clients
await _hubContext.Clients.All.SendAsync("UsersChanged");
// Also trigger locally if there are server-side subscribers
if (OnUsersChanged != null)
await OnUsersChanged.Invoke();
}
public async Task NotifyUserDeletedAsync(int userId)
{
// Broadcast via SignalR to all clients
await _hubContext.Clients.All.SendAsync("UserDeleted", userId);
// Also trigger locally if there are server-side subscribers
if (OnUserDeleted != null)
await OnUserDeleted.Invoke(userId);
}
}
Binary file not shown.
Binary file not shown.
@@ -1,59 +0,0 @@
using System.Diagnostics;
using System.Security.Claims;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.Components.Web;
using timetracker.Shared;
namespace timetracker.Server;
public class PersistingServerAuthenticationStateProvider : ServerAuthenticationStateProvider, IDisposable
{
private readonly PersistentComponentState _state;
private readonly PersistingComponentStateSubscription _subscription;
public PersistingServerAuthenticationStateProvider(PersistentComponentState state)
{
_state = state;
_subscription = state.RegisterOnPersisting(PersistAuthenticationState);
}
private async Task PersistAuthenticationState()
{
var authState = await GetAuthenticationStateAsync();
var user = authState.User;
if (user.Identity?.IsAuthenticated == true)
{
var idClaim = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var name = user.Identity.Name;
var isAdminClaim = user.FindFirst("IsTenantAdmin")?.Value;
if (idClaim != null && name != null)
{
var userInfo = new UserInfo
{
Id = int.Parse(idClaim),
Username = name,
IsTenantAdmin = isAdminClaim == "true",
IsAuthenticated = true
};
_state.PersistAsJson("UserInfoState", userInfo);
}
}
else
{
var userInfo = new UserInfo
{
IsAuthenticated = false
};
_state.PersistAsJson("UserInfoState", userInfo);
}
}
public void Dispose()
{
_subscription.Dispose();
}
}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More