27 Commits

Author SHA1 Message Date
mrcwlnd d5f68ca520 Merge branch 'main' into multi-tenant-integration 2026-06-25 18:34:01 +00:00
MarcWieland 0719d41fe1 changelog angepasst 2026-06-25 20:23:39 +02:00
MarcWieland b12e94128c Excel Export eingebaut 2026-06-25 20:22:35 +02:00
MarcWieland 2f95fa0e3e auch einzelnutzer können sich registrieren 2026-06-25 20:15:07 +02:00
MarcWieland ac06059e2a multi-tenant implementierung 2026-06-24 23:48:04 +02:00
mrcwlnd 8a0bf59994 Merge pull request 'feat: add vacation day management to home view and persist changelog version in local storage' (#9) from multi-tenant-integration into main
Reviewed-on: #9
2026-06-24 20:39:48 +00:00
MarcWieland d646cfd28d feat: add vacation day management to home view and persist changelog version in local storage 2026-06-24 22:39:22 +02:00
mrcwlnd 147b589d59 Merge pull request 'little bugfix' (#8) from multi-tenant-integration into main
Reviewed-on: #8
2026-06-24 20:33:35 +00:00
MarcWieland 24cbbbf624 little bugfix 2026-06-24 22:33:23 +02:00
mrcwlnd a379088a24 Merge pull request 'Huge updates' (#7) from navbar_styling into main
Reviewed-on: #7
2026-06-24 20:29:47 +00:00
MarcWieland 0d59d521dc Huge updates 2026-06-24 22:29:05 +02:00
mrcwlnd 9824616323 Merge pull request 'Neue agb seite implementiert' (#6) from navbar_styling into main
Reviewed-on: #6
2026-06-08 22:24:11 +00:00
MarcWieland 94c10aebdd Onboarding 2026-06-09 00:22:30 +02:00
MarcWieland ddb2b4af9f Neue agb seite implementiert 2026-06-09 00:15:06 +02:00
mrcwlnd 8bd7423209 Merge pull request 'Neue navbar' (#5) from navbar_styling into main
Reviewed-on: #5
2026-06-08 22:06:32 +00:00
MarcWieland 1def618e34 Neue navbar 2026-06-09 00:06:18 +02:00
MarcWieland 2029524379 Design updates 2026-06-08 23:52:22 +02:00
MarcWieland f92dd2659c Increased version 2026-06-08 23:36:04 +02:00
MarcWieland 82626bc5b3 Timebot implementation 2026-06-08 23:34:08 +02:00
mrcwlnd b8b01871ed Merge pull request 'Dynamischer page title' (#4) from wasm-integration into main
Reviewed-on: #4
2026-06-08 14:43:02 +00:00
MarcWieland d79ee1671d Dynamischer page title 2026-06-08 16:42:46 +02:00
mrcwlnd 69a38761f3 Merge pull request 'peformance verbesserungen' (#3) from wasm-integration into main
Reviewed-on: #3
2026-06-08 14:39:44 +00:00
MarcWieland 708aa3991a peformance verbesserungen 2026-06-08 16:39:28 +02:00
mrcwlnd 3f262910ad Merge pull request 'fixed dockerfile' (#2) from wasm-integration into main
Reviewed-on: #2
2026-06-08 14:32:13 +00:00
MarcWieland b3e578308f fixed dockerfile 2026-06-08 16:29:40 +02:00
mrcwlnd ed36d0e1ec Merge pull request 'WASM Mode activated' (#1) from wasm-integration into main
Reviewed-on: #1
2026-06-08 14:25:30 +00:00
MarcWieland 58e562adb1 WASM Mode activated 2026-06-08 16:24:51 +02:00
155 changed files with 7139 additions and 1330 deletions
Vendored
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 MiB

-26
View File
@@ -1,26 +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.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
@@ -1,76 +0,0 @@
@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
@@ -1,98 +0,0 @@
.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
@@ -1,36 +0,0 @@
<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
@@ -1,105 +0,0 @@
.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
@@ -1,31 +0,0 @@
<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
@@ -1,157 +0,0 @@
.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
@@ -1,63 +0,0 @@
// 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();
}
}
-140
View File
@@ -1,140 +0,0 @@
@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;
}
}
-96
View File
@@ -1,96 +0,0 @@
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);
}
}
-13
View File
@@ -1,13 +0,0 @@
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>();
}
-19
View File
@@ -1,19 +0,0 @@
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);
}
}
-132
View File
@@ -1,132 +0,0 @@
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();
+21 -7
View File
@@ -1,22 +1,36 @@
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: Dockerfile
dockerfile: timetracker.Server/Dockerfile
container_name: timetracker-app
restart: always
ports:
- "8090:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Production
- ASPNETCORE_HTTP_PORTS=8080 # Lassen wir so, da intern völlig okay
- TIMETRACKER_DB_PATH=/data/timetracker.db
- ASPNETCORE_HTTP_PORTS=8080
- DB_PROVIDER=PostgreSQL
- ConnectionStrings__DefaultConnection=Host=db;Database=timetracker;Username=timetracker_user;Password=SecretPassword123;
- EnableHttpsRedirect=false
volumes:
- timetracker_data:/data
depends_on:
- db
volumes:
timetracker_data:
name: timetracker_prod_data
pgdata:
name: timetracker_pgdata
+73
View File
@@ -0,0 +1,73 @@
# 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.
Executable
+16
View File
@@ -0,0 +1,16 @@
#!/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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

@@ -0,0 +1,297 @@
@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;
}
}
@@ -0,0 +1,334 @@
/* 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;
}
@@ -0,0 +1,205 @@
@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" />
</MudDrawer>
</Authorized>
</AuthorizeView>
<MudMainContent>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4 pb-8">
@Body
</MudContainer>
</MudMainContent>
<AuthorizeView>
<Authorized>
<ChatbotWidget />
</Authorized>
</AuthorizeView>
</MudLayout>
@code {
private bool _drawerOpen = true;
private bool _isDarkMode;
protected override async Task OnInitializedAsync()
{
UserNotificationService.OnUserDeleted += HandleUserDeleted;
try
{
var tenantInfo = await Http.GetFromJsonAsync<TenantInfo>("api/tenant/current");
if (tenantInfo?.IsTenant == true && !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;
}
}
catch
{
// Ignore background error
}
}
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;
}
@@ -0,0 +1,57 @@
/* 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;
}
@@ -0,0 +1,170 @@
@inject IJSRuntime JSRuntime
@inject HttpClient Http
<div class="nav-container onboarding-nav-menu">
@* --- 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" />
<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="Wochenübersicht" Placement="Placement.Right">
<MudNavLink Href="" Match="NavLinkMatch.All" 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 {
[Parameter] public EventCallback OnToggleDrawer { get; set; }
private bool _showVersionBadge;
private string _companyName = "Hauptdomain";
protected override async Task OnInitializedAsync()
{
try
{
var tenantInfo = await Http.GetFromJsonAsync<TenantInfo>("api/tenant/current");
if (tenantInfo?.IsTenant == true && !string.IsNullOrEmpty(tenantInfo.Name))
{
_companyName = tenantInfo.Name;
}
}
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;
}
}
@@ -0,0 +1,357 @@
/* 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 (Fixes active color contrast issue) */
::deep .custom-nav-link.active,
::deep .custom-nav-link .active {
background: linear-gradient(90deg, color-mix(in srgb, var(--mud-palette-secondary) 15%, transparent) 0%, color-mix(in srgb, var(--mud-palette-secondary) 4%, transparent) 100%) !important;
font-weight: 600 !important;
box-shadow: inset 3px 0 0 var(--mud-palette-secondary) !important;
}
::deep .custom-nav-link.active,
::deep .custom-nav-link .active,
::deep .custom-nav-link.active .mud-nav-link-text,
::deep .custom-nav-link .active .mud-nav-link-text,
::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;
}
/* Admin link color overrides & active state */
::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: linear-gradient(90deg, rgba(239, 68, 68, 0.15) 0%, rgba(239, 68, 68, 0.04) 100%) !important;
box-shadow: inset 3px 0 0 #EF4444 !important;
}
::deep .admin-link.active,
::deep .admin-link .active,
::deep .admin-link.active .mud-nav-link-text,
::deep .admin-link .active .mud-nav-link-text,
::deep .admin-link.active .mud-nav-link-icon-default,
::deep .admin-link .active .mud-nav-link-icon-default {
color: #EF4444 !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 --- */
/* Parent matches the mini drawer element (Note the correct Blazor CSS isolation placement) */
.mud-drawer--closed ::deep .logo-icon,
.mud-drawer--closed ::deep .brand-text {
display: none !important;
opacity: 0;
}
.mud-drawer--closed ::deep .logo-wrapper {
justify-content: center !important;
}
.mud-drawer--closed ::deep .drawer-toggle-btn {
margin-left: 0 !important;
}
.mud-drawer--closed ::deep .admin-divider-container {
padding: 16px 0 8px 0 !important;
justify-content: center !important;
}
.mud-drawer--closed ::deep .admin-divider-text {
display: none !important;
}
.mud-drawer--closed ::deep .admin-divider-line {
width: 24px !important;
flex: none !important;
}
.mud-drawer--closed ::deep .user-profile-card {
justify-content: center !important;
padding: 8px 0 !important;
}
.mud-drawer--closed ::deep .user-info {
display: none !important;
}
.mud-drawer--closed ::deep .version-text {
display: none !important;
}
/* Ensure link text is hidden in mini mode to prevent clunky clipping */
.mud-drawer--closed ::deep .custom-nav-link .mud-nav-link-text {
display: none !important;
}
/* Center link icons when mini and adjust spacing */
.mud-drawer--closed ::deep .custom-nav-link {
margin: 4px 6px !important;
padding: 8px 0 !important;
justify-content: center !important;
min-height: 40px !important;
display: flex !important;
align-items: center !important;
}
.mud-drawer--closed ::deep .custom-nav-link .mud-nav-link-icon-default {
margin-right: 0 !important;
}
@@ -1,10 +1,14 @@
@page "/admin/users"
@rendermode InteractiveServer
@rendermode InteractiveWebAssembly
@attribute [Authorize(Policy = "AdminOnly")]
@inject AuthService AuthService
@using System.Net.Http.Json
@using System.Security.Claims
@inject IAuthService AuthService
@inject ISnackbar Snackbar
@inject AuthenticationStateProvider AuthStateProvider
@inject timetracker.Data.UserNotificationService UserNotificationService
@inject IUserNotificationService UserNotificationService
@inject HttpClient Http
@inject IDialogService DialogService
@implements IDisposable
<PageTitle>Benutzerverwaltung Timetracker</PageTitle>
@@ -22,7 +26,7 @@ else
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, #B71C1C 0%, #7F0000 100%); color:white;">
Style="background: #991B1B; 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">
@@ -42,6 +46,7 @@ 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>
@@ -74,6 +79,34 @@ 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)
{
@@ -92,6 +125,12 @@ 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>
@if (context.Username != "marc")
{
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline"
@@ -114,9 +153,11 @@ else
@code {
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()
{
@@ -125,13 +166,31 @@ else
if (claim == null) return;
UserNotificationService.OnUsersChanged += RefreshUsers;
_users = await AuthService.GetAllUsersAsync();
try
{
_users = await AuthService.GetAllUsersAsync();
_tenants = await Http.GetFromJsonAsync<List<TenantStats>>("api/superadmin/tenants") ?? [];
}
catch (Exception ex)
{
Snackbar.Add($"Fehler beim Laden der Daten: {ex.Message}", Severity.Error);
}
_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);
}
@@ -144,27 +203,49 @@ 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();
var error = await AuthService.RenameUserAsync(user.Id, trimmed);
if (error != null)
// 1. Rename if username changed
if (trimmed != user.Username)
{
Snackbar.Add(error, Severity.Error);
return;
var error = await AuthService.RenameUserAsync(user.Id, trimmed);
if (error != null)
{
Snackbar.Add(error, Severity.Error);
return;
}
user.Username = trimmed;
}
user.Username = trimmed;
// 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;
}
_editUserId = null;
_editUsername = "";
Snackbar.Add($"Benutzer umbenannt zu \"{trimmed}\".", Severity.Success);
_editTenantId = null;
Snackbar.Add("Benutzerdaten erfolgreich aktualisiert.", Severity.Success);
}
private async Task DeleteUser(User user)
@@ -173,4 +254,29 @@ 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);
}
}
}
}
@@ -0,0 +1,139 @@
@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,24 +1,24 @@
@page "/changelog"
@rendermode InteractiveServer
@attribute [Authorize]
@rendermode InteractiveWebAssembly
@attribute [AllowAnonymous]
@inject IJSRuntime JSRuntime
<PageTitle>Changelog Timetracker</PageTitle>
<MudStack Spacing="4">
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, #3F51B5 0%, #1A237E 100%); color:white;">
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudIcon Icon="@Icons.Material.Filled.NewReleases" Style="color:white; font-size:2rem" />
<MudIcon Icon="@Icons.Material.Filled.NewReleases" Style="color: var(--mud-palette-primary-text); font-size:2rem" />
<MudStack Spacing="0">
<MudText Typo="Typo.h5" Style="color:white; font-weight:700">Changelog</MudText>
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">Versionshistorie &amp; Änderungen</MudText>
<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>
</MudStack>
</MudStack>
</MudPaper>
@foreach (var release in _releases)
@foreach (var release in Releases)
{
<MudCard Elevation="2" Class="rounded-xl">
<MudCardContent>
@@ -55,20 +55,40 @@
</MudStack>
@code {
private record ChangeEntry(string Tag, string Text);
private record Release(string Version, string Date, bool IsLatest, List<ChangeEntry> Entries);
public record ChangeEntry(string Tag, string Text);
public record Release(string Version, string Date, bool IsLatest, List<ChangeEntry> Entries, bool Highlighted = false);
private static Color GetTagColor(string tag) => tag switch
{
"Neu" => Color.Success,
"Fix" => Color.Error,
"Upgrade" => Color.Info,
_ => Color.Default
};
private readonly List<Release> _releases =
public static readonly List<Release> Releases =
[
new("1.1", "08.06.2026", true,
new("1.5", "25.06.2026", true,
[
new("Neu", "Userprofil angelegt"),
new("Upgrade", "Früheste Gehenszeit wird angezeigt"),
new("Upgrade", "Pausen werden dynamisch berechnet"),
new("Neu", "Excel-Export"),
new("Neu", "Multi-Tenant Integration"),
new("Upgrade", "Verbesserte Single-User Ansicht")
], Highlighted: true),
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("Neu", "Versionsnummer in der Navbar mit Link zum Changelog"),
new("Neu", "Changelog-Seite"),
@@ -89,4 +109,30 @@
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 InteractiveServer
@rendermode InteractiveWebAssembly
@attribute [Authorize]
@inject HolidayService HolidayService
@inject TimetrackerService TrackerService
@inject IHolidayService HolidayService
@inject ITimetrackerService TrackerService
@inject AuthenticationStateProvider AuthStateProvider
<PageTitle>Feiertage Timetracker</PageTitle>
@@ -20,7 +20,7 @@ else
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, #00897B 0%, #004D40 100%); color: white;">
Style="background: #0F766E; 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(0,137,123,0.08) 0%, transparent 100%); border-left: 4px solid #00897B;">
Style="background: linear-gradient(90deg, rgba(15,118,110,0.08) 0%, transparent 100%); border-left: 4px solid #0F766E;">
<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" />
@@ -1,10 +1,12 @@
@page "/"
@rendermode InteractiveServer
@rendermode InteractiveWebAssembly
@attribute [Authorize]
@inject TimetrackerService TrackerService
@inject HolidayService HolidayService
@inject ITimetrackerService TrackerService
@inject IHolidayService HolidayService
@inject ISnackbar Snackbar
@inject AuthenticationStateProvider AuthStateProvider
@inject IJSRuntime JSRuntime
@inject NavigationManager NavigationManager
<PageTitle>KW @_kw Wochenübersicht Timetracker</PageTitle>
@@ -20,29 +22,32 @@ else
<MudStack Spacing="3">
@* ── Wochen-Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, #3F51B5 0%, #1A237E 100%); color: white;">
<MudPaper Elevation="4" Class="pa-5 rounded-xl onboarding-week-header mud-theme-primary">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
Style="color:white" Size="Size.Large" OnClick="PrevWeek" />
Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="PrevWeek" />
<MudStack Spacing="0" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5" Style="color:white; font-weight:700; letter-spacing:0.5px">
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700; letter-spacing:0.5px">
@_weekLabel
</MudText>
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 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:white"
<MudButton Variant="Variant.Text" Style="color: var(--mud-palette-primary-text)"
OnClick="GoToCurrentWeek" Size="Size.Small">
Heute
</MudButton>
}
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
Style="color:white" Size="Size.Large" OnClick="NextWeek" />
Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="NextWeek" />
</MudStack>
</MudStack>
</MudPaper>
@@ -93,9 +98,9 @@ else
else
{
@* ── Arbeitstag: vollständige Karte ── *@
<MudCard @key="@day.Date" Elevation="@(isToday ? 6 : 2)" Class="rounded-xl"
<MudCard @key="@day.Date" Elevation="@(isToday ? 6 : 2)" Class="rounded-xl onboarding-day-card"
Style="@($"border-left: 4px solid {borderColor};")">
<MudCardHeader Style="@(isToday ? "background: linear-gradient(90deg, rgba(63,81,181,0.07) 0%, transparent 100%);" : "")">
<MudCardHeader Style="@(isToday ? "background: linear-gradient(90deg, rgba(14,165,233,0.07) 0%, transparent 100%);" : "")">
<CardHeaderContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudStack Spacing="0">
@@ -138,9 +143,30 @@ else
}
else if (!hasData)
{
<MudChip T="string" Color="Color.Default" Variant="Variant.Outlined" Size="Size.Small">
Nicht erfasst
</MudChip>
<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>
}
</MudStack>
</CardHeaderContent>
@@ -177,19 +203,43 @@ 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(day.TotalBreakTime)</MudText>
<MudText Typo="Typo.body1">@FormatTs(GetEffectiveBreakTime(day))</MudText>
</MudStack>
<MudText Color="Color.Secondary" Class="mb-1">=</MudText>
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Color="Color.Secondary">Netto</MudText>
<MudText Typo="Typo.body1" Color="Color.Primary" Style="font-weight:700">
@FormatTs(day.NetWork!.Value)
@FormatTs(GetEffectiveNetWork(day)!.Value)
</MudText>
</MudStack>
<MudDivider Vertical="true" FlexItem="true" Style="height:36px" />
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Color="Color.Secondary">Soll</MudText>
<MudText Typo="Typo.body1">@FormatTs(TimeSpan.FromHours(_settings.DailyTargetHours))</MudText>
<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>
</MudStack>
</MudStack>
</MudItem>
@@ -199,7 +249,7 @@ else
@* ── Fortschrittsbalken ── *@
@if (progressPct >= 0)
{
<MudTooltip Text="@($"Netto {FormatTs(day.NetWork ?? TimeSpan.Zero)} von {FormatTs(TimeSpan.FromHours(_settings.DailyTargetHours))} Soll")">
<MudTooltip Text="@($"Netto {FormatTs(GetEffectiveNetWork(day) ?? TimeSpan.Zero)} von {FormatTs(TimeSpan.FromHours(GetDailyTargetHours(day)))} Soll")">
<MudProgressLinear Value="@Math.Min(progressPct, 100)"
Color="@(progressPct >= 100 ? Color.Success : Color.Primary)"
Rounded="true" Class="mt-3" Style="height:6px" />
@@ -269,35 +319,35 @@ else
}
@* ── Wochensumme ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, #1A237E 0%, #283593 100%); color:white;">
<MudPaper Elevation="4" Class="pa-5 rounded-xl onboarding-week-summary"
Style="background: var(--mud-palette-primary-darken); color: var(--mud-palette-primary-text);">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-4">
<MudIcon Icon="@Icons.Material.Filled.Summarize" Style="color:rgba(255,255,255,0.8)" />
<MudText Typo="Typo.h6" Style="color:white; font-weight:600">Wochensumme</MudText>
<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>
</MudStack>
<MudGrid Spacing="3">
<MudItem xs="6" sm="3">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Brutto</MudText>
<MudText Typo="Typo.h5" Style="color:white">@FormatTs(WeekGross)</MudText>
<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>
</MudStack>
</MudItem>
<MudItem xs="6" sm="3">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Pausen</MudText>
<MudText Typo="Typo.h5" Style="color:white">@FormatTs(WeekBreaks)</MudText>
<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>
</MudStack>
</MudItem>
<MudItem xs="6" sm="3">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Netto</MudText>
<MudText Typo="Typo.h5" Style="color:#90CAF9; font-weight:700">@FormatTs(WeekNet)</MudText>
<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>
</MudStack>
</MudItem>
<MudItem xs="6" sm="3">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Gleitzeit</MudText>
<MudText Typo="Typo.h5" Style="@($"color:{(WeekOvertime >= TimeSpan.Zero ? "#A5D6A7" : "#FFCC80")}; font-weight:700")">
<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">
@FormatTs(WeekOvertime, sign: true)
</MudText>
</MudStack>
@@ -306,7 +356,7 @@ else
</MudPaper>
@* ── Gleitzeitkonto ── *@
<MudPaper Elevation="3" Class="pa-5 rounded-xl"
<MudPaper Elevation="3" Class="pa-5 rounded-xl onboarding-overtime-balance"
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">
@@ -328,6 +378,11 @@ else
</MudPaper>
</MudStack>
@if (_showOnboarding)
{
<OnboardingTour OnFinished="HandleOnboardingFinished" />
}
}
@code {
@@ -347,6 +402,8 @@ 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));
@@ -358,24 +415,64 @@ else
_userId = int.Parse(claim.Value);
_monday = GetMonday(DateOnly.FromDateTime(DateTime.Today));
_settings = await TrackerService.GetSettingsAsync(_userId);
await LoadWeek();
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
var loadWeekTask = LoadWeek();
var overtimeTask = TrackerService.GetTotalOvertimeAsync(_userId, _settings);
await Task.WhenAll(loadWeekTask, overtimeTask);
_totalOvertime = await overtimeTask;
try
{
var showOnb = await JSRuntime.InvokeAsync<string>("localStorage.getItem", "showOnboarding");
_showOnboarding = showOnb == "true";
}
catch
{
// Ignored during prerendering/SSR
}
_loading = false;
}
private async Task HandleOnboardingFinished()
{
_showOnboarding = false;
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "showOnboarding", "false");
}
private async Task LoadWeek()
{
Task<List<PublicHoliday>> holidaysTask;
if (_monday.Year != _holidayYear)
{
var list = await HolidayService.GetHolidaysAsync(_monday.Year, _settings.GermanState);
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;
_holidays = list.ToDictionary(h => h.Date, h => h.Name);
_holidayYear = _monday.Year;
}
var dbDays = await TrackerService.GetWeekAsync(_userId, _monday);
_vacations = await vacationsTask;
var dbDays = await dbDaysTask;
_days = Enumerable.Range(0, 7).Select(i =>
{
var date = _monday.AddDays(i);
return DayVm.From(dbDays.FirstOrDefault(d => d.Date == date), date, _userId);
var dayVm = DayVm.From(dbDays.FirstOrDefault(d => d.Date == date), date, _userId);
dayVm.IsVacation = _vacations.Any(v => v.Date == date);
return dayVm;
}).ToList();
BuildWeekLabels();
}
@@ -399,6 +496,10 @@ 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)
{
@@ -433,21 +534,61 @@ else
}
// ── Berechnungen ─────────────────────────────────────────────
private TimeSpan? GetOvertime(DayVm day) =>
day.NetWork.HasValue ? day.NetWork.Value - TimeSpan.FromHours(_settings.DailyTargetHours) : null;
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 int GetProgressPercent(DayVm day)
{
if (!day.NetWork.HasValue || _settings.DailyTargetHours <= 0) return -1;
return (int)(day.NetWork.Value.TotalHours / _settings.DailyTargetHours * 100);
var net = GetEffectiveNetWork(day);
var target = GetDailyTargetHours(day);
if (!net.HasValue || target <= 0) return -1;
return (int)(net.Value.TotalHours / target * 100);
}
private static string GetBorderColor(DayVm day, bool isWorkDay, List<string> errors)
private 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 (!day.NetWork.HasValue) return "#B0BEC5";
if (!GetEffectiveNetWork(day).HasValue) return "#B0BEC5";
return "#4CAF50";
}
@@ -474,11 +615,17 @@ 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 + d.TotalBreakTime);
private TimeSpan WeekNet => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (d.NetWork ?? 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 WeekOvertime => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (GetOvertime(d) ?? TimeSpan.Zero));
private static string FormatTs(TimeSpan ts, bool sign = false)
@@ -489,6 +636,62 @@ 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 ────────────────────────────────────────────────
private sealed class DayVm
{
@@ -498,6 +701,7 @@ 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;
@@ -0,0 +1,614 @@
@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 {
private TenantInfo? _tenantInfo;
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()
{
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,9 +1,10 @@
@page "/month"
@rendermode InteractiveServer
@rendermode InteractiveWebAssembly
@attribute [Authorize]
@inject TimetrackerService TrackerService
@inject HolidayService HolidayService
@inject ITimetrackerService TrackerService
@inject IHolidayService HolidayService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
<PageTitle>@_deCulture.DateTimeFormat.GetMonthName(_month) @_year Monatsübersicht Timetracker</PageTitle>
@@ -19,29 +20,32 @@ else
<MudStack Spacing="3">
@* ── Monats-Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, #3F51B5 0%, #1A237E 100%); color: white;">
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
Style="color:white" Size="Size.Large" OnClick="PrevMonth" />
Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="PrevMonth" />
<MudStack Spacing="0" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5" Style="color:white; font-weight:700; letter-spacing:0.5px">
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700; letter-spacing:0.5px">
@_deCulture.DateTimeFormat.GetMonthName(_month) @_year
</MudText>
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">
@_subLabel
</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
<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:white"
<MudButton Variant="Variant.Text" Style="color: var(--mud-palette-primary-text)"
OnClick="GoToCurrentMonth" Size="Size.Small">
Heute
</MudButton>
}
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
Style="color:white" Size="Size.Large" OnClick="NextMonth" />
Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="NextMonth" />
</MudStack>
</MudStack>
</MudPaper>
@@ -51,7 +55,7 @@ else
<MudCardContent Class="pa-0">
<MudSimpleTable Dense="true" Striped="false" Hover="true" Style="overflow-x:auto">
<thead>
<tr style="background: rgba(63,81,181,0.08);">
<tr style="background: rgba(14,165,233,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>
@@ -95,7 +99,7 @@ else
@* ── Monatszusammenfassung ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, rgba(63,81,181,0.08) 0%, rgba(26,35,126,0.04) 100%); border-left: 6px solid #3F51B5;">
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);">
<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>
@@ -181,9 +185,15 @@ else
private async Task LoadMonth()
{
var workDays = await TrackerService.GetMonthAsync(_userId, _year, _month);
var holidays = await HolidayService.GetHolidaysAsync(_year, _settings.GermanState);
var vacations = await TrackerService.GetVacationDaysAsync(_userId, _year);
var 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 holidayMap = holidays.ToDictionary(h => h.Date, h => h.Name);
var vacationSet = vacations.Select(v => v.Date).ToHashSet();
@@ -204,7 +214,10 @@ 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()));
net = gross - breaks;
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;
if (net < TimeSpan.Zero) net = null;
}
@@ -260,6 +273,11 @@ else
await LoadMonth();
}
private void ExportMonthExcel()
{
NavigationManager.NavigateTo($"api/tracker/export/month?year={_year}&month={_month}", forceLoad: true);
}
private static string FormatTs(TimeSpan ts, bool sign = false)
{
var neg = ts < TimeSpan.Zero;
@@ -0,0 +1,179 @@
@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;
}
}
}
@@ -0,0 +1,280 @@
@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 {
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()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return;
_userId = int.Parse(claim.Value);
_username = authState.User.Identity?.Name ?? "";
_newUsername = _username;
_isAdmin = _username.Equals("marc", StringComparison.OrdinalIgnoreCase);
_settings = await TrackerService.GetSettingsAsync(_userId);
_overtime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
_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();
}
}
}
@@ -0,0 +1,29 @@
@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,10 +1,14 @@
@page "/settings"
@rendermode InteractiveServer
@rendermode InteractiveWebAssembly
@attribute [Authorize]
@inject TimetrackerService TrackerService
@inject HolidayService HolidayService
@inject ITimetrackerService TrackerService
@inject IHolidayService HolidayService
@inject ISnackbar Snackbar
@inject AuthenticationStateProvider AuthStateProvider
@inject IJSRuntime JSRuntime
@inject NavigationManager Nav
@inject HttpClient Http
<PageTitle>Einstellungen Timetracker</PageTitle>
@@ -19,13 +23,12 @@ else
<MudStack Spacing="4">
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, #3F51B5 0%, #1A237E 100%); color:white;">
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudIcon Icon="@Icons.Material.Filled.Settings" Style="color:white; font-size:2rem" />
<MudIcon Icon="@Icons.Material.Filled.Settings" Style="color: var(--mud-palette-primary-text); font-size:2rem" />
<MudStack Spacing="0">
<MudText Typo="Typo.h5" Style="color:white; font-weight:700">Einstellungen</MudText>
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
<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">
Arbeitszeit, Arbeitstage und Urlaub konfigurieren
</MudText>
</MudStack>
@@ -187,6 +190,79 @@ 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 ── *@
@@ -437,6 +513,11 @@ else
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);
@@ -495,8 +576,31 @@ else
if (claim == null) return;
_userId = int.Parse(claim.Value);
_settings = await TrackerService.GetSettingsAsync(_userId);
await LoadVacations();
_holHolidays = await HolidayService.GetHolidaysAsync(_holYear, _settings.GermanState);
_isTenantAdmin = authState.User.IsInRole("TenantAdmin") || authState.User.HasClaim("IsTenantAdmin", "true");
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;
}
private async Task LoadVacations()
@@ -574,6 +678,49 @@ 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);
@@ -0,0 +1,481 @@
@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 {
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()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return;
_userId = int.Parse(claim.Value);
_settings = await TrackerService.GetSettingsAsync(_userId);
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));
_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);
}
private 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; }
}
}
@@ -0,0 +1,267 @@
@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 {
private bool _loading = true;
private List<TenantStats> _tenants = [];
protected override async Task OnInitializedAsync()
{
await LoadTenants();
}
private async Task LoadTenants()
{
_loading = true;
try
{
_tenants = await Http.GetFromJsonAsync<List<TenantStats>>("api/superadmin/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,8 +1,8 @@
@page "/urlaub-maximizer"
@rendermode InteractiveServer
@rendermode InteractiveWebAssembly
@attribute [Authorize]
@inject TimetrackerService TrackerService
@inject HolidayService HolidayService
@inject ITimetrackerService TrackerService
@inject IHolidayService HolidayService
@inject ISnackbar Snackbar
@inject AuthenticationStateProvider AuthStateProvider
@@ -21,7 +21,7 @@ else
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, #F57F17 0%, #E65100 100%); color:white;">
Style="background: #C2410C; 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" />
@@ -249,8 +249,13 @@ else
private async Task LoadYear()
{
var holidays = await HolidayService.GetHolidaysAsync(_year, _settings.GermanState);
var vacations = await TrackerService.GetVacationDaysAsync(_userId, _year);
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;
_holidays = holidays.ToDictionary(h => h.Date, h => h.Name);
_vacationSet = vacations.Select(v => v.Date).ToHashSet();
_remainingDays = Math.Max(0, _settings.VacationDaysPerYear - vacations.Count);
@@ -1,10 +1,10 @@
@rendermode InteractiveServer
@rendermode InteractiveWebAssembly
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Router AppAssembly="typeof(timetracker.Client._Imports).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(timetracker.Client.Components.Layout.MainLayout)">
<NotAuthorized>
<RedirectToLogin />
<timetracker.Client.Components.RedirectToLogin />
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="routeData" Selector="h1" />
@@ -0,0 +1,17 @@
@using System.Net.Http
@using System.Net.Http.Json
@using System.Security.Claims
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using timetracker.Client
@using timetracker.Client.Components
@using timetracker.Client.Components.Layout
@using timetracker.Client.Services
@using timetracker.Shared
@using MudBlazor
+27
View File
@@ -0,0 +1,27 @@
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using MudBlazor.Services;
using timetracker.Client.Services;
using timetracker.Shared;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddMudServices();
builder.Services.AddAuthorizationCore(options =>
{
options.AddPolicy("AdminOnly", policy =>
policy.RequireClaim(System.Security.Claims.ClaimTypes.Name, "marc"));
});
builder.Services.AddScoped<HostAuthenticationStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<HostAuthenticationStateProvider>());
builder.Services.AddScoped<IAuthService, ClientAuthService>();
builder.Services.AddScoped<ITimetrackerService, ClientTimetrackerService>();
builder.Services.AddScoped<IHolidayService, ClientHolidayService>();
builder.Services.AddScoped<IUserNotificationService, UserNotificationService>();
await builder.Build().RunAsync();
@@ -0,0 +1,25 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://localhost:5016",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "https://localhost:7270;http://localhost:5016",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,118 @@
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;
}
}
@@ -0,0 +1,67 @@
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; } = "";
}
}
@@ -0,0 +1,73 @@
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}") ?? [];
}
}
@@ -0,0 +1,77 @@
using System.Security.Claims;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Components.Authorization;
using timetracker.Shared;
namespace timetracker.Client.Services;
public class HostAuthenticationStateProvider : AuthenticationStateProvider
{
private readonly HttpClient _http;
private static readonly ClaimsPrincipal Anonymous = new(new ClaimsIdentity());
private ClaimsPrincipal? _currentUser;
public HostAuthenticationStateProvider(HttpClient http)
{
_http = http;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
if (_currentUser != null)
{
return new AuthenticationState(_currentUser);
}
try
{
var response = await _http.GetAsync("api/auth/me");
if (response.IsSuccessStatusCode)
{
var userInfo = await response.Content.ReadFromJsonAsync<UserInfo>();
if (userInfo != null)
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, userInfo.Id.ToString()),
new Claim(ClaimTypes.Name, userInfo.Username),
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);
}
}
}
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)));
}
}
@@ -0,0 +1,56 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.SignalR.Client;
using timetracker.Shared;
namespace timetracker.Client.Services;
public class UserNotificationService : IUserNotificationService, IAsyncDisposable
{
private readonly HubConnection _hubConnection;
public event Func<Task>? OnUsersChanged;
public event Func<int, Task>? OnUserDeleted;
public UserNotificationService(NavigationManager navigationManager)
{
_hubConnection = new HubConnectionBuilder()
.WithUrl(navigationManager.ToAbsoluteUri("/hubs/notifications"))
.WithAutomaticReconnect()
.Build();
_hubConnection.On("UsersChanged", async () =>
{
if (OnUsersChanged != null)
await OnUsersChanged.Invoke();
});
_hubConnection.On<int>("UserDeleted", async (userId) =>
{
if (OnUserDeleted != null)
await OnUserDeleted.Invoke(userId);
});
// Start connection asynchronously
_ = StartHubConnectionAsync();
}
private async Task StartHubConnectionAsync()
{
try
{
await _hubConnection.StartAsync();
}
catch (Exception ex)
{
Console.WriteLine($"SignalR Connection Error: {ex.Message}");
}
}
public async ValueTask DisposeAsync()
{
if (_hubConnection != null)
{
await _hubConnection.DisposeAsync();
}
}
}
+13
View File
@@ -0,0 +1,13 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using timetracker.Client
@using timetracker.Client.Components
@using timetracker.Client.Components.Layout
@using timetracker.Shared
@using MudBlazor
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.8" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.5" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.5" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.8" />
<PackageReference Include="MudBlazor" Version="9.4.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\timetracker.Shared\timetracker.Shared.csproj" />
</ItemGroup>
</Project>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

+53
View File
@@ -0,0 +1,53 @@
<!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 />
<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>
@@ -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,7 +10,8 @@
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using timetracker
@using timetracker.Components
@using timetracker.Components.Layout
@using timetracker.Client.Components
@using timetracker.Client.Components.Layout
@using timetracker.Data
@using timetracker.Shared
@using MudBlazor
+271
View File
@@ -0,0 +1,271 @@
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
{
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.";
// 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 (tenantId == null && username.Equals("marc", StringComparison.OrdinalIgnoreCase))
{
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.");
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 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
@@ -0,0 +1,278 @@
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 +1,11 @@
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)
public class HolidayService(IDbContextFactory<TimetrackerDbContext> factory, HttpClient http) : IHolidayService
{
private const string ApiUrl = "https://date.nager.at/api/v3/PublicHolidays/{0}/DE";
@@ -0,0 +1,10 @@
using System.Threading.Tasks;
using timetracker.Shared;
namespace timetracker.Data;
public interface ITenantProvider
{
int? TenantId { get; }
Task<Tenant?> GetCurrentTenantAsync();
}
@@ -2,6 +2,7 @@
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using timetracker.Data;
@@ -10,14 +11,16 @@ using timetracker.Data;
namespace timetracker.Data.Migrations
{
[DbContext(typeof(TimetrackerDbContext))]
partial class TimetrackerDbContextModelSnapshot : ModelSnapshot
[Migration("20260624204954_AddMultiTenancy")]
partial class AddMultiTenancy
{
protected override void BuildModel(ModelBuilder modelBuilder)
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
modelBuilder.Entity("timetracker.Data.AppSettings", b =>
modelBuilder.Entity("timetracker.Shared.AppSettings", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@@ -38,6 +41,9 @@ namespace timetracker.Data.Migrations
b.Property<int>("MinimumBreakMinutes")
.HasColumnType("INTEGER");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
@@ -70,7 +76,7 @@ namespace timetracker.Data.Migrations
b.ToTable("AppSettings");
});
modelBuilder.Entity("timetracker.Data.BreakEntry", b =>
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@@ -92,7 +98,7 @@ namespace timetracker.Data.Migrations
b.ToTable("BreakEntries");
});
modelBuilder.Entity("timetracker.Data.PublicHoliday", b =>
modelBuilder.Entity("timetracker.Shared.PublicHoliday", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@@ -113,7 +119,35 @@ namespace timetracker.Data.Migrations
b.ToTable("PublicHolidays");
});
modelBuilder.Entity("timetracker.Data.User", b =>
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 =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@@ -127,16 +161,21 @@ 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.Data.VacationDay", b =>
modelBuilder.Entity("timetracker.Shared.VacationDay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@@ -148,6 +187,9 @@ namespace timetracker.Data.Migrations
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
@@ -156,7 +198,7 @@ namespace timetracker.Data.Migrations
b.ToTable("VacationDays");
});
modelBuilder.Entity("timetracker.Data.WorkDay", b =>
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@@ -171,6 +213,9 @@ namespace timetracker.Data.Migrations
b.Property<TimeOnly?>("StartTime")
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
@@ -179,9 +224,9 @@ namespace timetracker.Data.Migrations
b.ToTable("WorkDays");
});
modelBuilder.Entity("timetracker.Data.BreakEntry", b =>
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
{
b.HasOne("timetracker.Data.WorkDay", "WorkDay")
b.HasOne("timetracker.Shared.WorkDay", "WorkDay")
.WithMany("Breaks")
.HasForeignKey("WorkDayId")
.OnDelete(DeleteBehavior.Cascade)
@@ -190,7 +235,16 @@ namespace timetracker.Data.Migrations
b.Navigation("WorkDay");
});
modelBuilder.Entity("timetracker.Data.WorkDay", b =>
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");
});
@@ -0,0 +1,104 @@
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");
}
}
}
@@ -0,0 +1,265 @@
// <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
}
}
}
@@ -0,0 +1,51 @@
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",
type: "INTEGER",
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");
}
}
}
@@ -0,0 +1,262 @@
// <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.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
}
}
}
@@ -0,0 +1,7 @@
using Microsoft.AspNetCore.SignalR;
namespace timetracker.Data;
public class NotificationHub : Hub
{
}
+126
View File
@@ -0,0 +1,126 @@
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
ResolveTenantAsync().GetAwaiter().GetResult();
if (httpContext.Items.TryGetValue("TenantId", out cachedId))
{
return (int?)cachedId;
}
return null;
}
}
public async Task<Tenant?> GetCurrentTenantAsync()
{
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext == null) return null;
if (httpContext.Items.TryGetValue("CurrentTenant", out var cachedTenant))
{
return (Tenant?)cachedTenant;
}
await ResolveTenantAsync();
if (httpContext.Items.TryGetValue("CurrentTenant", out cachedTenant))
{
return (Tenant?)cachedTenant;
}
return null;
}
private async Task ResolveTenantAsync()
{
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))
{
await using var db = await _dbContextFactory.CreateDbContextAsync();
var tenant = await db.Tenants
.FirstOrDefaultAsync(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;
}
}
@@ -0,0 +1,72 @@
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,8 +1,9 @@
using Microsoft.EntityFrameworkCore;
using timetracker.Shared;
namespace timetracker.Data;
public class TimetrackerService(IDbContextFactory<TimetrackerDbContext> factory)
public class TimetrackerService(IDbContextFactory<TimetrackerDbContext> factory) : ITimetrackerService
{
public async Task<List<WorkDay>> GetWeekAsync(int userId, DateOnly monday)
{
@@ -172,7 +173,10 @@ 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()));
actual = (gross - breakTotal).TotalHours;
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;
if (actual < 0) actual = 0.0;
}
}
@@ -0,0 +1,37 @@
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);
}
}
+17 -6
View File
@@ -2,11 +2,22 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Keine Unterordner mehr beim Kopieren!
COPY timetracker.csproj ./
RUN dotnet restore timetracker.csproj
# 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/
COPY . ./
# 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
RUN dotnet publish -c Release -o /app/publish
# ── Runtime Stage ─────────────────────────────────────────────────────────────
@@ -14,7 +25,7 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
# Verzeichnis für die SQLite-Datenbank
# Directory for SQLite database
RUN mkdir -p /data
ENV ASPNETCORE_HTTP_PORTS=8080
@@ -27,4 +38,4 @@ EXPOSE 8080
VOLUME ["/data"]
ENTRYPOINT ["dotnet", "timetracker.dll"]
ENTRYPOINT ["dotnet", "timetracker.Server.dll"]
+690
View File
@@ -0,0 +1,690 @@
using System.Security.Claims;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MudBlazor.Services;
using timetracker.Server.Components;
using timetracker.Data;
using timetracker.Shared;
var builder = WebApplication.CreateBuilder(args);
// Add Authentication
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/login";
options.LogoutPath = "/auth/logout";
options.ExpireTimeSpan = TimeSpan.FromDays(30);
options.SlidingExpiration = true;
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Strict;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
// Cookie-Konfiguration für APIs & WASM
options.Events.OnRedirectToLogin = context =>
{
if (context.Request.Path.StartsWithSegments("/api"))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
}
else
{
context.Response.Redirect(context.RedirectUri);
}
return Task.CompletedTask;
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy =>
policy.RequireClaim(ClaimTypes.Name, "marc"));
});
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddHttpContextAccessor();
builder.Services.AddTransient<ITenantProvider, TenantProvider>();
// Add SignalR
builder.Services.AddSignalR();
builder.Services.AddSingleton<UserNotificationService>();
builder.Services.AddSingleton<IUserNotificationService>(sp => sp.GetRequiredService<UserNotificationService>());
// Register DB-backed services as the interfaces
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<ITimetrackerService, TimetrackerService>();
builder.Services.AddScoped<IHolidayService, HolidayService>();
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveWebAssemblyComponents();
builder.Services.AddMudServices();
builder.Services.AddHttpClient<HolidayService>();
builder.Services.AddScoped(sp =>
{
try
{
var navigationManager = sp.GetService<Microsoft.AspNetCore.Components.NavigationManager>();
if (navigationManager != null && !string.IsNullOrEmpty(navigationManager.BaseUri))
{
return new System.Net.Http.HttpClient { BaseAddress = new Uri(navigationManager.BaseUri) };
}
}
catch (InvalidOperationException)
{
// NavigationManager is registered but not initialized in this request scope
}
var httpContextAccessor = sp.GetService<Microsoft.AspNetCore.Http.IHttpContextAccessor>();
var request = httpContextAccessor?.HttpContext?.Request;
if (request != null)
{
var baseUri = $"{request.Scheme}://{request.Host}{request.PathBase}/";
return new System.Net.Http.HttpClient { BaseAddress = new Uri(baseUri) };
}
return new System.Net.Http.HttpClient();
});
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("auth-limit", httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpContext.Connection.RemoteIpAddress?.ToString()
?? httpContext.Request.Headers["X-Forwarded-For"].ToString()
?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
Window = TimeSpan.FromMinutes(1),
PermitLimit = 5,
QueueLimit = 0
}));
});
var dbProvider = builder.Configuration["DB_PROVIDER"] ?? "SQLite";
if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase))
{
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
?? builder.Configuration["ConnectionStrings:DefaultConnection"];
builder.Services.AddDbContextFactory<TimetrackerDbContext>(options =>
options.UseNpgsql(connectionString));
}
else
{
var dbPath = Environment.GetEnvironmentVariable("TIMETRACKER_DB_PATH")
?? Path.Combine(builder.Environment.ContentRootPath, "timetracker.db");
builder.Services.AddDbContextFactory<TimetrackerDbContext>(options =>
options.UseSqlite($"Data Source={dbPath}"));
}
var app = builder.Build();
// Migrate or Ensure Database Created
using (var scope = app.Services.CreateScope())
{
var factory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<TimetrackerDbContext>>();
await using var db = await factory.CreateDbContextAsync();
if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase))
{
await db.Database.EnsureCreatedAsync();
}
else
{
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.UseWebAssemblyDebugging();
}
else
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
if (app.Configuration.GetValue("EnableHttpsRedirect", !app.Environment.IsDevelopment()))
{
app.UseHttpsRedirection();
}
app.UseStaticFiles();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapStaticAssets();
// Map Hub
app.MapHub<NotificationHub>("/hubs/notifications");
// Map Blazor WASM
app.MapRazorComponents<App>()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(timetracker.Client._Imports).Assembly);
// ── Auth-API-Endpoints ────────────────────────────────────────────────────────
app.MapGet("/api/auth/me", (HttpContext ctx) =>
{
if (ctx.User.Identity?.IsAuthenticated == true)
{
var idClaim = ctx.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var name = ctx.User.Identity.Name ?? "";
var isTenantAdmin = ctx.User.FindFirst("IsTenantAdmin")?.Value == "true";
if (int.TryParse(idClaim, out var id))
{
return Results.Ok(new UserInfo { Id = id, Username = name, IsTenantAdmin = isTenantAdmin });
}
}
return Results.Unauthorized();
});
app.MapPost("/api/auth/login", async (HttpContext ctx, [FromBody] LoginRequest req, IAuthService authService, ITenantProvider tenantProvider) =>
{
var tenant = await tenantProvider.GetCurrentTenantAsync();
if (tenant != null && !tenant.IsApproved)
{
return Results.BadRequest("Diese Firma ist noch nicht freigeschaltet.");
}
var user = await authService.LoginAsync(req.Username, req.Password);
if (user == null)
return Results.BadRequest("Benutzername oder Passwort falsch.");
if (tenant != null)
{
if (user.TenantId != tenant.Id)
{
return Results.BadRequest("Benutzer gehört nicht zu dieser Firma.");
}
}
else
{
if (user.Username != "marc" && user.TenantId != null)
{
return Results.BadRequest("Benutzer gehört zu einer Firma. Bitte melden Sie sich über Ihre Firmen-Subdomain an.");
}
}
var claims = new List<Claim> {
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username),
new Claim("IsTenantAdmin", user.IsTenantAdmin ? "true" : "false")
};
if (user.TenantId.HasValue)
{
claims.Add(new Claim("TenantId", user.TenantId.Value.ToString()));
}
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity),
new AuthenticationProperties { IsPersistent = true });
return Results.Ok(new UserInfo { Id = user.Id, Username = user.Username, IsTenantAdmin = user.IsTenantAdmin });
}).RequireRateLimiting("auth-limit");
app.MapPost("/api/auth/register", async (HttpContext ctx, [FromBody] RegisterRequest req, IAuthService authService, ITenantProvider tenantProvider) =>
{
if (!string.IsNullOrEmpty(req.Honeypot))
{
return Results.BadRequest("Registrierung fehlgeschlagen.");
}
var tenant = await tenantProvider.GetCurrentTenantAsync();
if (tenant != null && !tenant.IsApproved)
{
return Results.BadRequest("Registrierung blockiert: Firma noch nicht freigeschaltet.");
}
var (user, error) = await authService.RegisterAsync(req.Username, req.Password);
if (user == null)
return Results.BadRequest(error ?? "Registrierung fehlgeschlagen.");
var claims = new List<Claim> {
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username),
new Claim("IsTenantAdmin", user.IsTenantAdmin ? "true" : "false")
};
if (user.TenantId.HasValue)
{
claims.Add(new Claim("TenantId", user.TenantId.Value.ToString()));
}
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity),
new AuthenticationProperties { IsPersistent = true });
return Results.Ok(new UserInfo { Id = user.Id, Username = user.Username, IsTenantAdmin = user.IsTenantAdmin });
}).RequireRateLimiting("auth-limit");
app.MapGet("/auth/logout", async (HttpContext ctx) =>
{
await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Results.Redirect("/login");
});
// ── Admin-API-Endpoints (Protected) ───────────────────────────────────────────
var usersApi = app.MapGroup("/api/users").RequireAuthorization("AdminOnly");
usersApi.MapGet("/", async (IAuthService authService) =>
{
var users = await authService.GetAllUsersAsync();
return Results.Ok(users);
});
usersApi.MapDelete("/{userId:int}", async (int userId, IAuthService authService) =>
{
await authService.DeleteUserAsync(userId);
return Results.NoContent();
});
usersApi.MapPut("/{userId:int}/rename", async (int userId, [FromBody] RenameRequest req, IAuthService authService) =>
{
var error = await authService.RenameUserAsync(userId, req.Username);
if (error != null)
{
return Results.BadRequest(error);
}
return Results.Ok();
});
usersApi.MapPut("/{userId:int}/change-password", async (int userId, [FromBody] ChangePasswordRequest req, IAuthService authService) =>
{
var error = await authService.ChangePasswordAsync(userId, req.CurrentPassword, req.NewPassword);
if (error != null)
{
return Results.BadRequest(error);
}
return Results.Ok();
});
usersApi.MapPut("/{userId:int}/assign-tenant", async (int userId, [FromBody] AssignTenantRequest req, IAuthService authService) =>
{
var error = await authService.AssignTenantAsync(userId, req.TenantId);
if (error != null)
{
return Results.BadRequest(error);
}
return Results.Ok();
});
usersApi.MapPut("/{userId:int}/reset-password", async (int userId, [FromBody] ResetPasswordRequest req, IAuthService authService) =>
{
var error = await authService.ResetPasswordAsync(userId, req.NewPassword);
if (error != null)
{
return Results.BadRequest(error);
}
return Results.Ok();
});
// ── Timetracker-API-Endpoints (Protected) ─────────────────────────────────────
var trackerApi = app.MapGroup("/api/tracker").RequireAuthorization();
trackerApi.MapPost("/tenant/settings", async (ClaimsPrincipal claimsPrincipal, [FromBody] TenantSettingsDto dto, ITenantProvider tenantProvider, TimetrackerDbContext db) =>
{
var isAdmin = claimsPrincipal.FindFirst("IsTenantAdmin")?.Value == "true";
if (!isAdmin) return Results.Forbid();
var tenant = await tenantProvider.GetCurrentTenantAsync();
if (tenant == null) return Results.BadRequest("Keine Firma aktiv.");
var dbTenant = await db.Tenants.FindAsync(tenant.Id);
if (dbTenant == null) return Results.NotFound("Firma nicht gefunden.");
if (!string.IsNullOrWhiteSpace(dto.Name))
{
dbTenant.Name = dto.Name.Trim();
}
if (!string.IsNullOrWhiteSpace(dto.PrimaryColor))
{
dbTenant.PrimaryColor = dto.PrimaryColor.Trim();
}
if (!string.IsNullOrWhiteSpace(dto.SecondaryColor))
{
dbTenant.SecondaryColor = dto.SecondaryColor.Trim();
}
await db.SaveChangesAsync();
return Results.Ok();
});
trackerApi.MapGet("/week", async (ClaimsPrincipal claimsPrincipal, [FromQuery] string monday, ITimetrackerService trackerService) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
if (DateOnly.TryParse(monday, out var date))
{
var days = await trackerService.GetWeekAsync(userId, date);
return Results.Ok(days);
}
return Results.BadRequest("Ungültiges Datum.");
});
trackerApi.MapPost("/workday", async (ClaimsPrincipal claimsPrincipal, [FromBody] WorkDay workDay, ITimetrackerService trackerService) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
workDay.UserId = userId; // Enforce owner
await trackerService.UpsertWorkDayAsync(workDay);
return Results.Ok();
});
trackerApi.MapGet("/settings", async (ClaimsPrincipal claimsPrincipal, ITimetrackerService trackerService) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
var settings = await trackerService.GetSettingsAsync(userId);
return Results.Ok(settings);
});
trackerApi.MapPost("/settings", async (ClaimsPrincipal claimsPrincipal, [FromBody] AppSettings settings, ITimetrackerService trackerService) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
settings.UserId = userId; // Enforce owner
await trackerService.SaveSettingsAsync(settings);
return Results.Ok();
});
trackerApi.MapGet("/vacation/{year:int}", async (ClaimsPrincipal claimsPrincipal, int year, ITimetrackerService trackerService) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
var days = await trackerService.GetVacationDaysAsync(userId, year);
return Results.Ok(days);
});
trackerApi.MapPost("/vacation", async (ClaimsPrincipal claimsPrincipal, [FromBody] VacationDay vacationDay, ITimetrackerService trackerService) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
vacationDay.UserId = userId; // Enforce owner
await trackerService.AddVacationDayAsync(vacationDay);
return Results.Ok();
});
trackerApi.MapDelete("/vacation/{id:int}", async (ClaimsPrincipal claimsPrincipal, int id, ITimetrackerService trackerService) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
await trackerService.RemoveVacationDayAsync(userId, id);
return Results.NoContent();
});
trackerApi.MapPost("/overtime", async (ClaimsPrincipal claimsPrincipal, [FromBody] AppSettings settings, ITimetrackerService trackerService) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
settings.UserId = userId; // Enforce owner
var ts = await trackerService.GetTotalOvertimeAsync(userId, settings);
return Results.Ok(ts.TotalHours);
});
trackerApi.MapGet("/month", async (ClaimsPrincipal claimsPrincipal, [FromQuery] int year, [FromQuery] int month, ITimetrackerService trackerService) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
var days = await trackerService.GetMonthAsync(userId, year, month);
return Results.Ok(days);
});
trackerApi.MapGet("/export/week", async (ClaimsPrincipal claimsPrincipal, [FromQuery] string monday, ITimetrackerService trackerService, TimetrackerDbContext db) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
if (DateOnly.TryParse(monday, out var date))
{
var mondayDate = date;
int diff = ((int)mondayDate.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7;
mondayDate = mondayDate.AddDays(-diff);
var sundayDate = mondayDate.AddDays(6);
var fileBytes = await ExcelExporter.GenerateExcelExportAsync(
userId,
mondayDate,
sundayDate,
$"Wochenbericht KW {System.Globalization.ISOWeek.GetWeekOfYear(new DateTime(mondayDate.Year, mondayDate.Month, mondayDate.Day))}",
trackerService,
db);
return Results.File(
fileBytes,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
$"Wochenbericht_KW_{System.Globalization.ISOWeek.GetWeekOfYear(new DateTime(mondayDate.Year, mondayDate.Month, mondayDate.Day))}_{mondayDate:yyyyMMdd}.xlsx");
}
return Results.BadRequest("Ungültiges Datum.");
});
trackerApi.MapGet("/export/month", async (ClaimsPrincipal claimsPrincipal, [FromQuery] int year, [FromQuery] int month, ITimetrackerService trackerService, TimetrackerDbContext db) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
if (month < 1 || month > 12) return Results.BadRequest("Ungültiger Monat.");
var startDate = new DateOnly(year, month, 1);
var endDate = startDate.AddMonths(1).AddDays(-1);
var deCulture = new System.Globalization.CultureInfo("de-DE");
var monthName = deCulture.DateTimeFormat.GetMonthName(month);
var fileBytes = await ExcelExporter.GenerateExcelExportAsync(
userId,
startDate,
endDate,
$"Monatsbericht {monthName} {year}",
trackerService,
db);
return Results.File(
fileBytes,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
$"Monatsbericht_{monthName}_{year}.xlsx");
});
trackerApi.MapGet("/export/year", async (ClaimsPrincipal claimsPrincipal, [FromQuery] int year, ITimetrackerService trackerService, TimetrackerDbContext db) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
var startDate = new DateOnly(year, 1, 1);
var endDate = new DateOnly(year, 12, 31);
var fileBytes = await ExcelExporter.GenerateExcelExportAsync(
userId,
startDate,
endDate,
$"Jahresbericht {year}",
trackerService,
db);
return Results.File(
fileBytes,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
$"Jahresbericht_{year}.xlsx");
});
// ── Holiday-API-Endpoints (Protected) ─────────────────────────────────────────
var holidaysApi = app.MapGroup("/api/holidays").RequireAuthorization();
holidaysApi.MapGet("/", async ([FromQuery] int year, [FromQuery] string? stateCode, IHolidayService holidayService) =>
{
var list = await holidayService.GetHolidaysAsync(year, stateCode);
return Results.Ok(list);
});
holidaysApi.MapPost("/fetch/{year:int}", async (int year, IHolidayService holidayService) =>
{
var (success, message) = await holidayService.FetchAndStoreAsync(year);
return Results.Ok(new { Success = success, Message = message });
});
holidaysApi.MapDelete("/{id:int}", async (int id, IHolidayService holidayService) =>
{
await holidayService.DeleteAsync(id);
return Results.NoContent();
});
app.MapGet("/api/tenant/current", async (ITenantProvider tenantProvider) =>
{
var tenant = await tenantProvider.GetCurrentTenantAsync();
if (tenant == null)
{
return Results.Ok(new TenantInfo { IsTenant = false });
}
return Results.Ok(new TenantInfo
{
IsTenant = true,
Name = tenant.Name,
Subdomain = tenant.Subdomain,
IsApproved = tenant.IsApproved,
PrimaryColor = tenant.PrimaryColor,
SecondaryColor = tenant.SecondaryColor
});
});
app.MapPost("/api/auth/register-firm", async ([FromBody] RegisterFirmRequest req, IAuthService authService) =>
{
var (tenant, error) = await authService.RegisterFirmAsync(req);
if (tenant == null)
return Results.BadRequest(error ?? "Registrierung der Firma fehlgeschlagen.");
return Results.Ok(new { Success = true });
}).RequireRateLimiting("auth-limit");
// ── SuperAdmin-API-Endpoints (Protected) ───────────────────────────────────────────
var superAdminApi = app.MapGroup("/api/superadmin").RequireAuthorization("AdminOnly");
superAdminApi.MapGet("/tenants", async (TimetrackerDbContext db) =>
{
var tenants = await db.Tenants
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
var stats = new List<object>();
foreach (var t in tenants)
{
var userCount = await db.Users.IgnoreQueryFilters().CountAsync(u => u.TenantId == t.Id);
var workDaysCount = await db.WorkDays.IgnoreQueryFilters().CountAsync(w => w.TenantId == t.Id);
var tenantUsers = await db.Users.IgnoreQueryFilters()
.Where(u => u.TenantId == t.Id)
.Select(u => new { u.Id, u.Username, u.IsTenantAdmin })
.ToListAsync();
stats.Add(new
{
t.Id,
t.Name,
t.Subdomain,
t.IsApproved,
t.CreatedAt,
UserCount = userCount,
WorkDaysCount = workDaysCount,
Users = tenantUsers
});
}
return Results.Ok(stats);
});
superAdminApi.MapPost("/tenants/{id:int}/approve", async (int id, TimetrackerDbContext db) =>
{
var tenant = await db.Tenants.FindAsync(id);
if (tenant == null) return Results.NotFound();
tenant.IsApproved = true;
await db.SaveChangesAsync();
return Results.Ok();
});
superAdminApi.MapPost("/tenants/{id:int}/toggle-active", async (int id, TimetrackerDbContext db) =>
{
var tenant = await db.Tenants.FindAsync(id);
if (tenant == null) return Results.NotFound();
tenant.IsApproved = !tenant.IsApproved;
await db.SaveChangesAsync();
return Results.Ok();
});
superAdminApi.MapDelete("/tenants/{id:int}", async (int id, TimetrackerDbContext db, UserNotificationService notifier) =>
{
var tenant = await db.Tenants.FindAsync(id);
if (tenant == null) return Results.NotFound();
await using var transaction = await db.Database.BeginTransactionAsync();
try
{
var userIds = await db.Users.IgnoreQueryFilters()
.Where(u => u.TenantId == id)
.Select(u => u.Id)
.ToListAsync();
var workDays = await db.WorkDays.IgnoreQueryFilters().Where(w => w.TenantId == id).ToListAsync();
db.WorkDays.RemoveRange(workDays);
var vacationDays = await db.VacationDays.IgnoreQueryFilters().Where(v => v.TenantId == id).ToListAsync();
db.VacationDays.RemoveRange(vacationDays);
var settings = await db.AppSettings.IgnoreQueryFilters().Where(s => s.TenantId == id).ToListAsync();
db.AppSettings.RemoveRange(settings);
var users = await db.Users.IgnoreQueryFilters().Where(u => u.TenantId == id).ToListAsync();
db.Users.RemoveRange(users);
db.Tenants.Remove(tenant);
await db.SaveChangesAsync();
await transaction.CommitAsync();
foreach (var uid in userIds)
{
await notifier.NotifyUserDeletedAsync(uid);
}
await notifier.NotifyUsersChangedAsync();
return Results.NoContent();
}
catch (Exception ex)
{
await transaction.RollbackAsync();
return Results.BadRequest($"Fehler beim Löschen der Firma: {ex.Message}");
}
});
app.Run();
// ── Models for Request Bodies ──────────────────────────────────────────────────
public record LoginRequest(string Username, string Password);
public record RegisterRequest(string Username, string Password, string? Honeypot = null);
public record RenameRequest(string Username);
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
@@ -8,10 +8,17 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ClosedXML" Version="0.105.0" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
<PackageReference Include="MudBlazor" Version="9.4.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\timetracker.Shared\timetracker.Shared.csproj" />
<ProjectReference Include="..\timetracker.Client\timetracker.Client.csproj" />
</ItemGroup>
</Project>

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

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