Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6300251b1 |
Binary file not shown.
|
Before Width: | Height: | Size: 5.5 MiB |
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<ResourcePreloader />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
|
||||
<link rel="stylesheet" href="_content/MudBlazor/MudBlazor.min.css" />
|
||||
<link rel="stylesheet" href="@Assets["app.css"]" />
|
||||
<link rel="stylesheet" href="@Assets["timetracker.styles.css"]" />
|
||||
<ImportMap />
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||
<link rel="alternate icon" type="image/png" href="favicon.png" />
|
||||
<HeadOutlet />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes />
|
||||
<ReconnectModal />
|
||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,76 @@
|
||||
@inherits LayoutComponentBase
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Nav
|
||||
@inject timetracker.Data.UserNotificationService UserNotificationService
|
||||
@implements IDisposable
|
||||
@using System.Security.Claims
|
||||
|
||||
<MudThemeProvider Theme="_theme" />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
<MudLayout>
|
||||
<MudAppBar Elevation="2" Style="background: linear-gradient(90deg, #3F51B5 0%, #1A237E 100%);">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" OnClick="ToggleDrawer" />
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="ml-2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AccessTime" Style="color:white; font-size:1.6rem" />
|
||||
<MudText Typo="Typo.h6" Style="color:white; font-weight:700; letter-spacing:0.5px">Timetracker</MudText>
|
||||
</MudStack>
|
||||
<MudSpacer />
|
||||
</MudAppBar>
|
||||
|
||||
<MudDrawer @bind-Open="_drawerOpen" ClipMode="DrawerClipMode.Always" Elevation="2">
|
||||
<NavMenu />
|
||||
</MudDrawer>
|
||||
|
||||
<MudMainContent>
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4 pb-8">
|
||||
@Body
|
||||
</MudContainer>
|
||||
</MudMainContent>
|
||||
</MudLayout>
|
||||
|
||||
@code {
|
||||
private bool _drawerOpen = true;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
UserNotificationService.OnUserDeleted += HandleUserDeleted;
|
||||
}
|
||||
|
||||
private async Task HandleUserDeleted(int deletedUserId)
|
||||
{
|
||||
var state = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var idClaim = state.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (idClaim != null && int.TryParse(idClaim, out var myId) && myId == deletedUserId)
|
||||
{
|
||||
await InvokeAsync(() => Nav.NavigateTo("/auth/logout", forceLoad: true));
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
UserNotificationService.OnUserDeleted -= HandleUserDeleted;
|
||||
}
|
||||
|
||||
private readonly MudTheme _theme = new()
|
||||
{
|
||||
PaletteLight = new PaletteLight
|
||||
{
|
||||
Primary = "#3F51B5",
|
||||
PrimaryDarken = "#1A237E",
|
||||
PrimaryLighten = "#7986CB",
|
||||
Secondary = "#009688",
|
||||
SecondaryDarken = "#00695C",
|
||||
AppbarBackground = "#3F51B5",
|
||||
Background = "#F4F6F9",
|
||||
DrawerBackground = "#FFFFFF",
|
||||
Surface = "#FFFFFF",
|
||||
TextPrimary = "#212121",
|
||||
TextSecondary = "#757575",
|
||||
}
|
||||
};
|
||||
|
||||
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
.page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
background-color: #f7f7f7;
|
||||
border-bottom: 1px solid #d6d5d5;
|
||||
justify-content: flex-end;
|
||||
height: 3.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
white-space: nowrap;
|
||||
margin-left: 1.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.top-row ::deep a:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (max-width: 640.98px) {
|
||||
.top-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.page {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
height: 100vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.top-row {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.top-row.auth ::deep a:first-child {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.top-row, article {
|
||||
padding-left: 2rem !important;
|
||||
padding-right: 1.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
#blazor-error-ui {
|
||||
color-scheme: light only;
|
||||
background: lightyellow;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<div style="display:flex; flex-direction:column; height:100%;">
|
||||
<MudNavMenu Style="flex:1">
|
||||
<MudDivider Class="mb-2" />
|
||||
<MudNavLink Href="" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.CalendarMonth">Wochenübersicht</MudNavLink>
|
||||
<MudNavLink Href="month" Icon="@Icons.Material.Filled.CalendarViewMonth">Monatsübersicht</MudNavLink>
|
||||
<MudNavLink Href="feiertage" Icon="@Icons.Material.Filled.Celebration">Feiertage</MudNavLink>
|
||||
<MudNavLink Href="urlaub-maximizer" Icon="@Icons.Material.Filled.AutoAwesome">Urlaubs-Maximizer</MudNavLink>
|
||||
<MudNavLink Href="settings" Icon="@Icons.Material.Filled.Settings">Einstellungen</MudNavLink>
|
||||
<MudDivider Class="mt-2 mb-2" />
|
||||
<AuthorizeView Policy="AdminOnly">
|
||||
<MudNavLink Href="admin/users" Icon="@Icons.Material.Filled.AdminPanelSettings" IconColor="Color.Error">
|
||||
Benutzerverwaltung
|
||||
</MudNavLink>
|
||||
</AuthorizeView>
|
||||
</MudNavMenu>
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<MudDivider />
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Class="px-4 py-2" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AccountCircle" Color="Color.Primary" Size="Size.Small" />
|
||||
<MudText Typo="Typo.body2" Style="font-weight:600">@context.User.Identity?.Name</MudText>
|
||||
</MudStack>
|
||||
<MudNavMenu>
|
||||
<MudNavLink Href="/auth/logout" Icon="@Icons.Material.Filled.Logout">Abmelden</MudNavLink>
|
||||
</MudNavMenu>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
<MudTooltip Text="Changelog anzeigen" Placement="Placement.Top">
|
||||
<MudNavMenu>
|
||||
<MudNavLink Href="/changelog" Icon="@Icons.Material.Filled.NewReleases"
|
||||
Style="color: var(--mud-palette-text-disabled); font-size:0.75rem;">
|
||||
Version 1.1
|
||||
</MudNavLink>
|
||||
</MudNavMenu>
|
||||
</MudTooltip>
|
||||
</div>
|
||||
@@ -0,0 +1,105 @@
|
||||
.navbar-toggler {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
width: 3.5rem;
|
||||
height: 2.5rem;
|
||||
color: white;
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.navbar-toggler:checked {
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
min-height: 3.5rem;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.bi {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-right: 0.75rem;
|
||||
top: -1px;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.bi-house-door-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-plus-square-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-list-nested-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
font-size: 0.9rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-item:first-of-type {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.nav-item:last-of-type {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link {
|
||||
color: #d7d7d7;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 3rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-item ::deep a.active {
|
||||
background-color: rgba(255,255,255,0.37);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link:hover {
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-toggler:checked ~ .nav-scrollable {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.navbar-toggler {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
/* Never collapse the sidebar for wide screens */
|
||||
display: block;
|
||||
|
||||
/* Allow sidebar to scroll for tall menus */
|
||||
height: calc(100vh - 3.5rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
|
||||
|
||||
<dialog id="components-reconnect-modal" data-nosnippet>
|
||||
<div class="components-reconnect-container">
|
||||
<div class="components-rejoining-animation" aria-hidden="true">
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
<p class="components-reconnect-first-attempt-visible">
|
||||
Rejoining the server...
|
||||
</p>
|
||||
<p class="components-reconnect-repeated-attempt-visible">
|
||||
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
|
||||
</p>
|
||||
<p class="components-reconnect-failed-visible">
|
||||
Failed to rejoin.<br />Please retry or reload the page.
|
||||
</p>
|
||||
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
|
||||
Retry
|
||||
</button>
|
||||
<p class="components-pause-visible">
|
||||
The session has been paused by the server.
|
||||
</p>
|
||||
<p class="components-resume-failed-visible">
|
||||
Failed to resume the session.<br />Please retry or reload the page.
|
||||
</p>
|
||||
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
|
||||
Resume
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -0,0 +1,157 @@
|
||||
.components-reconnect-first-attempt-visible,
|
||||
.components-reconnect-repeated-attempt-visible,
|
||||
.components-reconnect-failed-visible,
|
||||
.components-pause-visible,
|
||||
.components-resume-failed-visible,
|
||||
.components-rejoining-animation {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
|
||||
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
|
||||
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
|
||||
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
|
||||
#components-reconnect-modal.components-reconnect-retrying,
|
||||
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
|
||||
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
|
||||
#components-reconnect-modal.components-reconnect-failed,
|
||||
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
#components-reconnect-modal {
|
||||
background-color: white;
|
||||
width: 20rem;
|
||||
margin: 20vh auto;
|
||||
padding: 2rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
|
||||
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
|
||||
&[open]
|
||||
|
||||
{
|
||||
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#components-reconnect-modal::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-slideUp {
|
||||
0% {
|
||||
transform: translateY(30px) scale(0.95);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-fadeInOpacity {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-fadeOutOpacity {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.components-reconnect-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
#components-reconnect-modal p {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button {
|
||||
border: 0;
|
||||
background-color: #6b9ed2;
|
||||
color: white;
|
||||
padding: 4px 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button:hover {
|
||||
background-color: #3b6ea2;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button:active {
|
||||
background-color: #6b9ed2;
|
||||
}
|
||||
|
||||
.components-rejoining-animation {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.components-rejoining-animation div {
|
||||
position: absolute;
|
||||
border: 3px solid #0087ff;
|
||||
opacity: 1;
|
||||
border-radius: 50%;
|
||||
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
|
||||
}
|
||||
|
||||
.components-rejoining-animation div:nth-child(2) {
|
||||
animation-delay: -0.5s;
|
||||
}
|
||||
|
||||
@keyframes components-rejoining-animation {
|
||||
0% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
4.9% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
5% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Set up event handlers
|
||||
const reconnectModal = document.getElementById("components-reconnect-modal");
|
||||
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
|
||||
|
||||
const retryButton = document.getElementById("components-reconnect-button");
|
||||
retryButton.addEventListener("click", retry);
|
||||
|
||||
const resumeButton = document.getElementById("components-resume-button");
|
||||
resumeButton.addEventListener("click", resume);
|
||||
|
||||
function handleReconnectStateChanged(event) {
|
||||
if (event.detail.state === "show") {
|
||||
reconnectModal.showModal();
|
||||
} else if (event.detail.state === "hide") {
|
||||
reconnectModal.close();
|
||||
} else if (event.detail.state === "failed") {
|
||||
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
} else if (event.detail.state === "rejected") {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function retry() {
|
||||
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
|
||||
try {
|
||||
// Reconnect will asynchronously return:
|
||||
// - true to mean success
|
||||
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
|
||||
// - exception to mean we didn't reach the server (this can be sync or async)
|
||||
const successful = await Blazor.reconnect();
|
||||
if (!successful) {
|
||||
// We have been able to reach the server, but the circuit is no longer available.
|
||||
// We'll reload the page so the user can continue using the app as quickly as possible.
|
||||
const resumeSuccessful = await Blazor.resumeCircuit();
|
||||
if (!resumeSuccessful) {
|
||||
location.reload();
|
||||
} else {
|
||||
reconnectModal.close();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// We got an exception, server is currently unavailable
|
||||
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
}
|
||||
}
|
||||
|
||||
async function resume() {
|
||||
try {
|
||||
const successful = await Blazor.resumeCircuit();
|
||||
if (!successful) {
|
||||
location.reload();
|
||||
}
|
||||
} catch {
|
||||
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function retryWhenDocumentBecomesVisible() {
|
||||
if (document.visibilityState === "visible") {
|
||||
await retry();
|
||||
}
|
||||
}
|
||||
+11
-117
@@ -1,14 +1,10 @@
|
||||
@page "/admin/users"
|
||||
@rendermode InteractiveWebAssembly
|
||||
@rendermode InteractiveServer
|
||||
@attribute [Authorize(Policy = "AdminOnly")]
|
||||
@using System.Net.Http.Json
|
||||
@using System.Security.Claims
|
||||
@inject IAuthService AuthService
|
||||
@inject AuthService AuthService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject IUserNotificationService UserNotificationService
|
||||
@inject HttpClient Http
|
||||
@inject IDialogService DialogService
|
||||
@inject timetracker.Data.UserNotificationService UserNotificationService
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>Benutzerverwaltung – Timetracker</PageTitle>
|
||||
@@ -26,7 +22,7 @@ else
|
||||
|
||||
@* ── Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
||||
Style="background: #991B1B; color:white;">
|
||||
Style="background: linear-gradient(135deg, #B71C1C 0%, #7F0000 100%); color:white;">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AdminPanelSettings" Style="color:white; font-size:2rem" />
|
||||
<MudStack Spacing="0">
|
||||
@@ -46,7 +42,6 @@ else
|
||||
<HeaderContent>
|
||||
<MudTh><MudTableSortLabel SortBy="new Func<User, object>(u => u.Id)">ID</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortBy="new Func<User, object>(u => u.Username)" InitialDirection="SortDirection.Ascending">Benutzername</MudTableSortLabel></MudTh>
|
||||
<MudTh>Firma / Mandant</MudTh>
|
||||
<MudTh Style="text-align:right">Aktionen</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
@@ -79,34 +74,6 @@ else
|
||||
</MudStack>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
@if (_editUserId == context.Id)
|
||||
{
|
||||
<MudSelect @bind-Value="_editTenantId" Margin="Margin.Dense" T="int?" Label="Firma zuweisen" Variant="Variant.Outlined" Style="max-width:250px">
|
||||
<MudSelectItem T="int?" Value="@((int?)null)">Hauptdomain (System/Global)</MudSelectItem>
|
||||
@foreach (var tenant in _tenants)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)tenant.Id)">@tenant.Name (@tenant.Subdomain)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (context.TenantId.HasValue)
|
||||
{
|
||||
var t = _tenants.FirstOrDefault(x => x.Id == context.TenantId.Value);
|
||||
<MudChip T="string" Color="Color.Primary" Variant="Variant.Outlined" Size="Size.Small">
|
||||
@(t?.Name ?? $"Firma #{context.TenantId}")
|
||||
</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Color="Color.Default" Variant="Variant.Text" Size="Size.Small">
|
||||
@(context.Username == "marc" ? "SuperAdmin" : "Einzelnutzer")
|
||||
</MudChip>
|
||||
}
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd Style="text-align:right">
|
||||
@if (_editUserId == context.Id)
|
||||
{
|
||||
@@ -125,12 +92,6 @@ else
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small"
|
||||
OnClick="@(() => StartEdit(context))" />
|
||||
<MudTooltip Text="Passwort zurücksetzen">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.LockReset"
|
||||
Color="Color.Warning"
|
||||
Size="Size.Small"
|
||||
OnClick="@(() => OpenResetPasswordDialog(context))" />
|
||||
</MudTooltip>
|
||||
@if (context.Username != "marc")
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline"
|
||||
@@ -153,11 +114,9 @@ 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()
|
||||
{
|
||||
@@ -166,31 +125,13 @@ else
|
||||
if (claim == null) return;
|
||||
|
||||
UserNotificationService.OnUsersChanged += RefreshUsers;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
_users = await AuthService.GetAllUsersAsync();
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task RefreshUsers()
|
||||
{
|
||||
_users = await AuthService.GetAllUsersAsync();
|
||||
try
|
||||
{
|
||||
_tenants = await Http.GetFromJsonAsync<List<TenantStats>>("api/superadmin/tenants") ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore background refresh errors
|
||||
}
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
@@ -203,49 +144,27 @@ else
|
||||
{
|
||||
_editUserId = user.Id;
|
||||
_editUsername = user.Username;
|
||||
_editTenantId = user.TenantId;
|
||||
}
|
||||
|
||||
private void CancelEdit()
|
||||
{
|
||||
_editUserId = null;
|
||||
_editUsername = "";
|
||||
_editTenantId = null;
|
||||
}
|
||||
|
||||
private async Task SaveRename(User user)
|
||||
{
|
||||
var trimmed = _editUsername.Trim();
|
||||
|
||||
// 1. Rename if username changed
|
||||
if (trimmed != user.Username)
|
||||
var error = await AuthService.RenameUserAsync(user.Id, trimmed);
|
||||
if (error != null)
|
||||
{
|
||||
var error = await AuthService.RenameUserAsync(user.Id, trimmed);
|
||||
if (error != null)
|
||||
{
|
||||
Snackbar.Add(error, Severity.Error);
|
||||
return;
|
||||
}
|
||||
user.Username = trimmed;
|
||||
Snackbar.Add(error, Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Move to tenant if tenant ID changed
|
||||
if (_editTenantId != user.TenantId)
|
||||
{
|
||||
var error = await AuthService.AssignTenantAsync(user.Id, _editTenantId);
|
||||
if (error != null)
|
||||
{
|
||||
Snackbar.Add(error, Severity.Error);
|
||||
return;
|
||||
}
|
||||
user.TenantId = _editTenantId;
|
||||
}
|
||||
|
||||
user.Username = trimmed;
|
||||
_editUserId = null;
|
||||
_editUsername = "";
|
||||
_editTenantId = null;
|
||||
|
||||
Snackbar.Add("Benutzerdaten erfolgreich aktualisiert.", Severity.Success);
|
||||
Snackbar.Add($"Benutzer umbenannt zu \"{trimmed}\".", Severity.Success);
|
||||
}
|
||||
|
||||
private async Task DeleteUser(User user)
|
||||
@@ -254,29 +173,4 @@ else
|
||||
_users.Remove(user);
|
||||
Snackbar.Add($"Benutzer \"{user.Username}\" gelöscht.", Severity.Info);
|
||||
}
|
||||
|
||||
private async Task OpenResetPasswordDialog(User user)
|
||||
{
|
||||
var parameters = new DialogParameters<ResetPasswordDialog>
|
||||
{
|
||||
{ x => x.Username, user.Username }
|
||||
};
|
||||
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||
var dialog = await DialogService.ShowAsync<ResetPasswordDialog>("Passwort zurücksetzen", parameters, options);
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (result != null && !result.Canceled && result.Data is string newPassword)
|
||||
{
|
||||
var error = await AuthService.ResetPasswordAsync(user.Id, newPassword);
|
||||
if (error != null)
|
||||
{
|
||||
Snackbar.Add(error, Severity.Error);
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Passwort für {user.Username} wurde erfolgreich zurückgesetzt.", Severity.Success);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-66
@@ -1,24 +1,24 @@
|
||||
@page "/changelog"
|
||||
@rendermode InteractiveWebAssembly
|
||||
@attribute [AllowAnonymous]
|
||||
@inject IJSRuntime JSRuntime
|
||||
@rendermode InteractiveServer
|
||||
@attribute [Authorize]
|
||||
|
||||
<PageTitle>Changelog – Timetracker</PageTitle>
|
||||
|
||||
<MudStack Spacing="4">
|
||||
|
||||
@* ── Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
||||
Style="background: linear-gradient(135deg, #3F51B5 0%, #1A237E 100%); color:white;">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
|
||||
<MudIcon Icon="@Icons.Material.Filled.NewReleases" Style="color: var(--mud-palette-primary-text); font-size:2rem" />
|
||||
<MudIcon Icon="@Icons.Material.Filled.NewReleases" Style="color:white; font-size:2rem" />
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700">Changelog</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">Versionshistorie & Änderungen</MudText>
|
||||
<MudText Typo="Typo.h5" Style="color:white; font-weight:700">Changelog</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">Versionshistorie & Änderungen</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@foreach (var release in Releases)
|
||||
@foreach (var release in _releases)
|
||||
{
|
||||
<MudCard Elevation="2" Class="rounded-xl">
|
||||
<MudCardContent>
|
||||
@@ -55,40 +55,20 @@
|
||||
</MudStack>
|
||||
|
||||
@code {
|
||||
public record ChangeEntry(string Tag, string Text);
|
||||
public record Release(string Version, string Date, bool IsLatest, List<ChangeEntry> Entries, bool Highlighted = false);
|
||||
private record ChangeEntry(string Tag, string Text);
|
||||
private record Release(string Version, string Date, bool IsLatest, List<ChangeEntry> Entries);
|
||||
|
||||
public static readonly List<Release> Releases =
|
||||
private static Color GetTagColor(string tag) => tag switch
|
||||
{
|
||||
"Neu" => Color.Success,
|
||||
"Fix" => Color.Error,
|
||||
"Upgrade" => Color.Info,
|
||||
_ => Color.Default
|
||||
};
|
||||
|
||||
private readonly List<Release> _releases =
|
||||
[
|
||||
new("1.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("1.1", "08.06.2026", true,
|
||||
[
|
||||
new("Neu", "Versionsnummer in der Navbar mit Link zum Changelog"),
|
||||
new("Neu", "Changelog-Seite"),
|
||||
@@ -109,30 +89,4 @@
|
||||
new("Neu", "Registrierung und Login"),
|
||||
]),
|
||||
];
|
||||
|
||||
public static string LatestVersion => Releases.FirstOrDefault()?.Version ?? "";
|
||||
public static bool IsLatestHighlighted => Releases.FirstOrDefault()?.Highlighted ?? false;
|
||||
|
||||
private static Color GetTagColor(string tag) => tag switch
|
||||
{
|
||||
"Neu" => Color.Success,
|
||||
"Fix" => Color.Error,
|
||||
"Upgrade" => Color.Info,
|
||||
_ => Color.Default
|
||||
};
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
try
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "lastSeenVersion", LatestVersion);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignored in SSR
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -1,8 +1,8 @@
|
||||
@page "/feiertage"
|
||||
@rendermode InteractiveWebAssembly
|
||||
@rendermode InteractiveServer
|
||||
@attribute [Authorize]
|
||||
@inject IHolidayService HolidayService
|
||||
@inject ITimetrackerService TrackerService
|
||||
@inject HolidayService HolidayService
|
||||
@inject TimetrackerService TrackerService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
|
||||
<PageTitle>Feiertage – Timetracker</PageTitle>
|
||||
@@ -20,7 +20,7 @@ else
|
||||
|
||||
@* ── Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
||||
Style="background: #0F766E; color: white;">
|
||||
Style="background: linear-gradient(135deg, #00897B 0%, #004D40 100%); color: white;">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
|
||||
Style="color:white" Size="Size.Large" OnClick="PrevYear" />
|
||||
@@ -140,7 +140,7 @@ else
|
||||
|
||||
@* ── Zusammenfassung ── *@
|
||||
<MudPaper Elevation="2" Class="pa-4 rounded-xl"
|
||||
Style="background: linear-gradient(90deg, rgba(15,118,110,0.08) 0%, transparent 100%); border-left: 4px solid #0F766E;">
|
||||
Style="background: linear-gradient(90deg, rgba(0,137,123,0.08) 0%, transparent 100%); border-left: 4px solid #00897B;">
|
||||
<MudStack Row="true" Spacing="4" Wrap="Wrap.Wrap" AlignItems="AlignItems.Center">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Celebration" Style="color:#00897B" />
|
||||
@@ -1,12 +1,10 @@
|
||||
@page "/"
|
||||
@rendermode InteractiveWebAssembly
|
||||
@rendermode InteractiveServer
|
||||
@attribute [Authorize]
|
||||
@inject ITimetrackerService TrackerService
|
||||
@inject IHolidayService HolidayService
|
||||
@inject TimetrackerService TrackerService
|
||||
@inject HolidayService HolidayService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<PageTitle>KW @_kw – Wochenübersicht – Timetracker</PageTitle>
|
||||
|
||||
@@ -22,32 +20,29 @@ else
|
||||
<MudStack Spacing="3">
|
||||
|
||||
@* ── Wochen-Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl onboarding-week-header mud-theme-primary">
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
||||
Style="background: linear-gradient(135deg, #3F51B5 0%, #1A237E 100%); color: white;">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
|
||||
Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="PrevWeek" />
|
||||
Style="color:white" Size="Size.Large" OnClick="PrevWeek" />
|
||||
<MudStack Spacing="0" AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700; letter-spacing:0.5px">
|
||||
<MudText Typo="Typo.h5" Style="color:white; font-weight:700; letter-spacing:0.5px">
|
||||
@_weekLabel
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">
|
||||
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
|
||||
@_weekSubLabel
|
||||
</MudText>
|
||||
</MudStack>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
|
||||
<MudTooltip Text="Woche als Excel exportieren">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Download"
|
||||
Style="color: var(--mud-palette-primary-text)" OnClick="ExportWeekExcel" />
|
||||
</MudTooltip>
|
||||
@if (!IsCurrentWeek)
|
||||
{
|
||||
<MudButton Variant="Variant.Text" Style="color: var(--mud-palette-primary-text)"
|
||||
<MudButton Variant="Variant.Text" Style="color:white"
|
||||
OnClick="GoToCurrentWeek" Size="Size.Small">
|
||||
Heute
|
||||
</MudButton>
|
||||
}
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
|
||||
Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="NextWeek" />
|
||||
Style="color:white" Size="Size.Large" OnClick="NextWeek" />
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
@@ -98,9 +93,9 @@ else
|
||||
else
|
||||
{
|
||||
@* ── Arbeitstag: vollständige Karte ── *@
|
||||
<MudCard @key="@day.Date" Elevation="@(isToday ? 6 : 2)" Class="rounded-xl onboarding-day-card"
|
||||
<MudCard @key="@day.Date" Elevation="@(isToday ? 6 : 2)" Class="rounded-xl"
|
||||
Style="@($"border-left: 4px solid {borderColor};")">
|
||||
<MudCardHeader Style="@(isToday ? "background: linear-gradient(90deg, rgba(14,165,233,0.07) 0%, transparent 100%);" : "")">
|
||||
<MudCardHeader Style="@(isToday ? "background: linear-gradient(90deg, rgba(63,81,181,0.07) 0%, transparent 100%);" : "")">
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||
<MudStack Spacing="0">
|
||||
@@ -143,30 +138,9 @@ else
|
||||
}
|
||||
else if (!hasData)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
@if (day.IsVacation)
|
||||
{
|
||||
<MudChip T="string" Color="Color.Info" Variant="Variant.Filled" Size="Size.Small" Style="font-weight:700">
|
||||
URLAUB
|
||||
</MudChip>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Error"
|
||||
OnClick="@(async () => await RemoveVacation(day))"
|
||||
Style="text-transform:none; font-size: 0.75rem;">
|
||||
Urlaub entfernen
|
||||
</MudButton>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Color="Color.Default" Variant="Variant.Outlined" Size="Size.Small">
|
||||
Nicht erfasst
|
||||
</MudChip>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Info"
|
||||
OnClick="@(async () => await MarkAsVacation(day))"
|
||||
Style="text-transform:none; font-size: 0.75rem;">
|
||||
Als Urlaub eintragen
|
||||
</MudButton>
|
||||
}
|
||||
</MudStack>
|
||||
<MudChip T="string" Color="Color.Default" Variant="Variant.Outlined" Size="Size.Small">
|
||||
Nicht erfasst
|
||||
</MudChip>
|
||||
}
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
@@ -203,43 +177,19 @@ else
|
||||
<MudText Color="Color.Secondary" Class="mb-1">−</MudText>
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Pausen</MudText>
|
||||
<MudText Typo="Typo.body1">@FormatTs(GetEffectiveBreakTime(day))</MudText>
|
||||
<MudText Typo="Typo.body1">@FormatTs(day.TotalBreakTime)</MudText>
|
||||
</MudStack>
|
||||
<MudText Color="Color.Secondary" Class="mb-1">=</MudText>
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Netto</MudText>
|
||||
<MudText Typo="Typo.body1" Color="Color.Primary" Style="font-weight:700">
|
||||
@FormatTs(GetEffectiveNetWork(day)!.Value)
|
||||
@FormatTs(day.NetWork!.Value)
|
||||
</MudText>
|
||||
</MudStack>
|
||||
<MudDivider Vertical="true" FlexItem="true" Style="height:36px" />
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Soll</MudText>
|
||||
<MudText Typo="Typo.body1">@FormatTs(TimeSpan.FromHours(GetDailyTargetHours(day)))</MudText>
|
||||
</MudStack>
|
||||
<MudDivider Vertical="true" FlexItem="true" Style="height:36px" />
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Soll-Ende</MudText>
|
||||
<MudText Typo="Typo.body1">@FormatTimeSpan(CalculateEarliestDeparture(day)) Uhr</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
}
|
||||
else if (day.Start.HasValue)
|
||||
{
|
||||
<MudItem xs="12" md="8">
|
||||
<MudStack Row="true" Spacing="3" AlignItems="AlignItems.Center" Class="h-100 pl-1">
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-info);">Früheste Gehenszeit</MudText>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ExitToApp" Color="Color.Info" Size="Size.Small" />
|
||||
<MudText Typo="Typo.body1" Color="Color.Info" Style="font-weight:700">
|
||||
@FormatTimeSpan(CalculateEarliestDeparture(day)) Uhr
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="ml-2">
|
||||
(Bei Soll: @FormatTs(TimeSpan.FromHours(_settings.DailyTargetHours))h + @((int)GetRequiredBreakForTarget(day).TotalMinutes) Min. Pause)
|
||||
</MudText>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.body1">@FormatTs(TimeSpan.FromHours(_settings.DailyTargetHours))</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
@@ -249,7 +199,7 @@ else
|
||||
@* ── Fortschrittsbalken ── *@
|
||||
@if (progressPct >= 0)
|
||||
{
|
||||
<MudTooltip Text="@($"Netto {FormatTs(GetEffectiveNetWork(day) ?? TimeSpan.Zero)} von {FormatTs(TimeSpan.FromHours(GetDailyTargetHours(day)))} Soll")">
|
||||
<MudTooltip Text="@($"Netto {FormatTs(day.NetWork ?? TimeSpan.Zero)} von {FormatTs(TimeSpan.FromHours(_settings.DailyTargetHours))} Soll")">
|
||||
<MudProgressLinear Value="@Math.Min(progressPct, 100)"
|
||||
Color="@(progressPct >= 100 ? Color.Success : Color.Primary)"
|
||||
Rounded="true" Class="mt-3" Style="height:6px" />
|
||||
@@ -319,35 +269,35 @@ else
|
||||
}
|
||||
|
||||
@* ── Wochensumme ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl onboarding-week-summary"
|
||||
Style="background: var(--mud-palette-primary-darken); color: var(--mud-palette-primary-text);">
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
||||
Style="background: linear-gradient(135deg, #1A237E 0%, #283593 100%); color:white;">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-4">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Summarize" Style="color: var(--mud-palette-primary-text); opacity: 0.8" />
|
||||
<MudText Typo="Typo.h6" Style="color: var(--mud-palette-primary-text); font-weight:600">Wochensumme</MudText>
|
||||
<MudIcon Icon="@Icons.Material.Filled.Summarize" Style="color:rgba(255,255,255,0.8)" />
|
||||
<MudText Typo="Typo.h6" Style="color:white; font-weight:600">Wochensumme</MudText>
|
||||
</MudStack>
|
||||
<MudGrid Spacing="3">
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-primary-text); opacity: 0.6">Brutto</MudText>
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text)">@FormatTs(WeekGross)</MudText>
|
||||
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Brutto</MudText>
|
||||
<MudText Typo="Typo.h5" Style="color:white">@FormatTs(WeekGross)</MudText>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-primary-text); opacity: 0.6">Pausen</MudText>
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text)">@FormatTs(WeekBreaks)</MudText>
|
||||
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Pausen</MudText>
|
||||
<MudText Typo="Typo.h5" Style="color:white">@FormatTs(WeekBreaks)</MudText>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-primary-text); opacity: 0.6">Netto</MudText>
|
||||
<MudText Typo="Typo.h5" Color="Color.Secondary" Style="font-weight:700">@FormatTs(WeekNet)</MudText>
|
||||
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Netto</MudText>
|
||||
<MudText Typo="Typo.h5" Style="color:#90CAF9; font-weight:700">@FormatTs(WeekNet)</MudText>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-primary-text); opacity: 0.6">Gleitzeit</MudText>
|
||||
<MudText Typo="Typo.h5" Color="@(WeekOvertime >= TimeSpan.Zero ? Color.Success : Color.Warning)" Style="font-weight:700">
|
||||
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Gleitzeit</MudText>
|
||||
<MudText Typo="Typo.h5" Style="@($"color:{(WeekOvertime >= TimeSpan.Zero ? "#A5D6A7" : "#FFCC80")}; font-weight:700")">
|
||||
@FormatTs(WeekOvertime, sign: true)
|
||||
</MudText>
|
||||
</MudStack>
|
||||
@@ -356,7 +306,7 @@ else
|
||||
</MudPaper>
|
||||
|
||||
@* ── Gleitzeitkonto ── *@
|
||||
<MudPaper Elevation="3" Class="pa-5 rounded-xl onboarding-overtime-balance"
|
||||
<MudPaper Elevation="3" Class="pa-5 rounded-xl"
|
||||
Style="@($"border-left: 6px solid {(_totalOvertime >= TimeSpan.Zero ? "#4CAF50" : "#FF9800")};")">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Wrap="Wrap.Wrap" Spacing="3">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
@@ -378,11 +328,6 @@ else
|
||||
</MudPaper>
|
||||
|
||||
</MudStack>
|
||||
|
||||
@if (_showOnboarding)
|
||||
{
|
||||
<OnboardingTour OnFinished="HandleOnboardingFinished" />
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
@@ -402,8 +347,6 @@ 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));
|
||||
|
||||
@@ -415,64 +358,24 @@ else
|
||||
_userId = int.Parse(claim.Value);
|
||||
_monday = GetMonday(DateOnly.FromDateTime(DateTime.Today));
|
||||
_settings = await TrackerService.GetSettingsAsync(_userId);
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
await LoadWeek();
|
||||
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
|
||||
_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)
|
||||
{
|
||||
holidaysTask = HolidayService.GetHolidaysAsync(_monday.Year, _settings.GermanState);
|
||||
}
|
||||
else
|
||||
{
|
||||
holidaysTask = Task.FromResult(new List<PublicHoliday>());
|
||||
}
|
||||
|
||||
var dbDaysTask = TrackerService.GetWeekAsync(_userId, _monday);
|
||||
var vacationsTask = TrackerService.GetVacationDaysAsync(_userId, _monday.Year);
|
||||
|
||||
await Task.WhenAll(holidaysTask, dbDaysTask, vacationsTask);
|
||||
|
||||
if (_monday.Year != _holidayYear)
|
||||
{
|
||||
var list = await holidaysTask;
|
||||
var list = await HolidayService.GetHolidaysAsync(_monday.Year, _settings.GermanState);
|
||||
_holidays = list.ToDictionary(h => h.Date, h => h.Name);
|
||||
_holidayYear = _monday.Year;
|
||||
}
|
||||
|
||||
_vacations = await vacationsTask;
|
||||
var dbDays = await dbDaysTask;
|
||||
var dbDays = await TrackerService.GetWeekAsync(_userId, _monday);
|
||||
_days = Enumerable.Range(0, 7).Select(i =>
|
||||
{
|
||||
var date = _monday.AddDays(i);
|
||||
var dayVm = DayVm.From(dbDays.FirstOrDefault(d => d.Date == date), date, _userId);
|
||||
dayVm.IsVacation = _vacations.Any(v => v.Date == date);
|
||||
return dayVm;
|
||||
return DayVm.From(dbDays.FirstOrDefault(d => d.Date == date), date, _userId);
|
||||
}).ToList();
|
||||
BuildWeekLabels();
|
||||
}
|
||||
@@ -496,10 +399,6 @@ else
|
||||
private async Task PrevWeek() { _monday = _monday.AddDays(-7); await LoadWeek(); }
|
||||
private async Task NextWeek() { _monday = _monday.AddDays(7); await LoadWeek(); }
|
||||
private async Task GoToCurrentWeek() { _monday = GetMonday(DateOnly.FromDateTime(DateTime.Today)); await LoadWeek(); }
|
||||
private void ExportWeekExcel()
|
||||
{
|
||||
NavigationManager.NavigateTo($"api/tracker/export/week?monday={_monday:yyyy-MM-dd}", forceLoad: true);
|
||||
}
|
||||
|
||||
private static DateOnly GetMonday(DateOnly date)
|
||||
{
|
||||
@@ -534,61 +433,21 @@ else
|
||||
}
|
||||
|
||||
// ── Berechnungen ─────────────────────────────────────────────
|
||||
private TimeSpan GetEffectiveBreakTime(DayVm day)
|
||||
{
|
||||
var enteredBreak = day.Breaks
|
||||
.Where(b => b.Duration.HasValue)
|
||||
.Aggregate(TimeSpan.Zero, (s, b) => s + b.Duration!.Value);
|
||||
|
||||
if (!day.Start.HasValue || !day.End.HasValue)
|
||||
return enteredBreak;
|
||||
|
||||
var gross = day.End.Value - day.Start.Value;
|
||||
int minMinutes = gross.TotalHours >= 9.0 ? 45 : 30;
|
||||
int finalMin = Math.Max(minMinutes, _settings.MinimumBreakMinutes);
|
||||
var minBreak = TimeSpan.FromMinutes(finalMin);
|
||||
|
||||
return enteredBreak > minBreak ? enteredBreak : minBreak;
|
||||
}
|
||||
|
||||
private TimeSpan? GetEffectiveNetWork(DayVm day)
|
||||
{
|
||||
if (!day.GrossWork.HasValue) return null;
|
||||
return day.GrossWork.Value - GetEffectiveBreakTime(day);
|
||||
}
|
||||
|
||||
private double GetDailyTargetHours(DayVm day)
|
||||
{
|
||||
var isHoliday = _holidays.ContainsKey(day.Date);
|
||||
var isWorkDay = _settings.IsWorkDay(day.Date.DayOfWeek);
|
||||
if (!isWorkDay || isHoliday || day.IsVacation)
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
return _settings.DailyTargetHours;
|
||||
}
|
||||
|
||||
private TimeSpan? GetOvertime(DayVm day)
|
||||
{
|
||||
var net = GetEffectiveNetWork(day);
|
||||
return net.HasValue ? net.Value - TimeSpan.FromHours(GetDailyTargetHours(day)) : null;
|
||||
}
|
||||
private TimeSpan? GetOvertime(DayVm day) =>
|
||||
day.NetWork.HasValue ? day.NetWork.Value - TimeSpan.FromHours(_settings.DailyTargetHours) : null;
|
||||
|
||||
private int GetProgressPercent(DayVm day)
|
||||
{
|
||||
var net = GetEffectiveNetWork(day);
|
||||
var target = GetDailyTargetHours(day);
|
||||
if (!net.HasValue || target <= 0) return -1;
|
||||
return (int)(net.Value.TotalHours / target * 100);
|
||||
if (!day.NetWork.HasValue || _settings.DailyTargetHours <= 0) return -1;
|
||||
return (int)(day.NetWork.Value.TotalHours / _settings.DailyTargetHours * 100);
|
||||
}
|
||||
|
||||
private string GetBorderColor(DayVm day, bool isWorkDay, List<string> errors)
|
||||
private static string GetBorderColor(DayVm day, bool isWorkDay, List<string> errors)
|
||||
{
|
||||
if (!isWorkDay) return "#CFD8DC";
|
||||
if (errors.Count > 0) return "#EF5350";
|
||||
if (day.IsVacation) return "#0EA5E9"; // Cool blue for vacation
|
||||
if (!day.Start.HasValue && !day.End.HasValue) return "#B0BEC5";
|
||||
if (!GetEffectiveNetWork(day).HasValue) return "#B0BEC5";
|
||||
if (!day.NetWork.HasValue) return "#B0BEC5";
|
||||
return "#4CAF50";
|
||||
}
|
||||
|
||||
@@ -615,17 +474,11 @@ else
|
||||
yield return $"Pause {i + 1} und Pause {j + 1} überschneiden sich.";
|
||||
}
|
||||
}
|
||||
|
||||
var net = GetEffectiveNetWork(day);
|
||||
if (net.HasValue && net.Value.TotalHours > 10.0)
|
||||
{
|
||||
yield return "Die tägliche Arbeitszeit überschreitet das gesetzliche Maximum von 10 Stunden!";
|
||||
}
|
||||
}
|
||||
|
||||
private TimeSpan WeekGross => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (d.GrossWork ?? TimeSpan.Zero));
|
||||
private TimeSpan WeekBreaks => _days.Aggregate(TimeSpan.Zero, (s, d) => s + GetEffectiveBreakTime(d));
|
||||
private TimeSpan WeekNet => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (GetEffectiveNetWork(d) ?? TimeSpan.Zero));
|
||||
private TimeSpan WeekBreaks => _days.Aggregate(TimeSpan.Zero, (s, d) => s + d.TotalBreakTime);
|
||||
private TimeSpan WeekNet => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (d.NetWork ?? TimeSpan.Zero));
|
||||
private TimeSpan WeekOvertime => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (GetOvertime(d) ?? TimeSpan.Zero));
|
||||
|
||||
private static string FormatTs(TimeSpan ts, bool sign = false)
|
||||
@@ -636,62 +489,6 @@ 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
|
||||
{
|
||||
@@ -701,7 +498,6 @@ else
|
||||
public TimeSpan? Start { get; set; }
|
||||
public TimeSpan? End { get; set; }
|
||||
public List<BreakVm> Breaks { get; set; } = [];
|
||||
public bool IsVacation { get; set; }
|
||||
|
||||
public TimeSpan? GrossWork =>
|
||||
Start.HasValue && End.HasValue && End > Start ? End.Value - Start.Value : null;
|
||||
@@ -0,0 +1,140 @@
|
||||
@page "/login"
|
||||
@attribute [AllowAnonymous]
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<PageTitle>Anmelden – Timetracker</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Small" Class="mt-16">
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="4">
|
||||
|
||||
@* ── Logo / Header ── *@
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AccessTime"
|
||||
Style="font-size:4rem; color:#1565C0" />
|
||||
<MudText Typo="Typo.h4" Style="font-weight:700; color:#1565C0">Timetracker</MudText>
|
||||
</MudStack>
|
||||
|
||||
<MudPaper Elevation="4" Class="pa-6 rounded-xl" Style="width:100%">
|
||||
@* ── Static Tab Navigation ── *@
|
||||
<MudStack Row="true" Justify="Justify.Center" Class="mb-4">
|
||||
<MudButton Href="/login"
|
||||
Variant="@(_activeTab == 0 ? Variant.Filled : Variant.Text)"
|
||||
Color="Color.Primary"
|
||||
Style="min-width: 120px; border-radius: 20px;">
|
||||
Anmelden
|
||||
</MudButton>
|
||||
<MudButton Href="/login?tab=register"
|
||||
Variant="@(_activeTab == 1 ? Variant.Filled : Variant.Text)"
|
||||
Color="Color.Primary"
|
||||
Style="min-width: 120px; border-radius: 20px;">
|
||||
Registrieren
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
<MudDivider Class="mb-6" />
|
||||
|
||||
@if (_activeTab == 0)
|
||||
{
|
||||
@* ── Login Form ── *@
|
||||
<MudStack Spacing="3">
|
||||
@if (_error != null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
|
||||
}
|
||||
<form action="/auth/login" method="post">
|
||||
<MudStack Spacing="3">
|
||||
<MudTextField T="string"
|
||||
Label="Benutzername"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Person"
|
||||
name="username"
|
||||
Required="true"
|
||||
AutoFocus="true" />
|
||||
<MudTextField T="string"
|
||||
Label="Passwort"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Lock"
|
||||
InputType="InputType.Password"
|
||||
name="password"
|
||||
Required="true" />
|
||||
<MudButton ButtonType="ButtonType.Submit"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
FullWidth="true"
|
||||
Size="Size.Large"
|
||||
StartIcon="@Icons.Material.Filled.Login"
|
||||
Class="mt-2">
|
||||
Anmelden
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</form>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ── Register Form ── *@
|
||||
<MudStack Spacing="3">
|
||||
@if (_error != null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
|
||||
}
|
||||
<form action="/auth/register" method="post">
|
||||
<MudStack Spacing="3">
|
||||
<MudTextField T="string"
|
||||
Label="Benutzername"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Person"
|
||||
name="username"
|
||||
Required="true"
|
||||
HelperText="Mindestens 3 Zeichen" />
|
||||
<MudTextField T="string"
|
||||
Label="Passwort"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Lock"
|
||||
InputType="InputType.Password"
|
||||
name="password"
|
||||
Required="true"
|
||||
HelperText="Mindestens 6 Zeichen" />
|
||||
<MudButton ButtonType="ButtonType.Submit"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Secondary"
|
||||
FullWidth="true"
|
||||
Size="Size.Large"
|
||||
StartIcon="@Icons.Material.Filled.PersonAdd"
|
||||
Class="mt-2">
|
||||
Konto erstellen
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</form>
|
||||
</MudStack>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
private int _activeTab = 0;
|
||||
private string? _error;
|
||||
|
||||
[SupplyParameterFromQuery(Name = "error")]
|
||||
public string? ErrorParam { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery(Name = "tab")]
|
||||
public string? TabParam { get; set; }
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_error = ErrorParam switch
|
||||
{
|
||||
"invalid" => "Benutzername oder Passwort falsch.",
|
||||
not null => Uri.UnescapeDataString(ErrorParam),
|
||||
_ => null
|
||||
};
|
||||
_activeTab = TabParam == "register" ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
@page "/month"
|
||||
@rendermode InteractiveWebAssembly
|
||||
@rendermode InteractiveServer
|
||||
@attribute [Authorize]
|
||||
@inject ITimetrackerService TrackerService
|
||||
@inject IHolidayService HolidayService
|
||||
@inject TimetrackerService TrackerService
|
||||
@inject HolidayService HolidayService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<PageTitle>@_deCulture.DateTimeFormat.GetMonthName(_month) @_year – Monatsübersicht – Timetracker</PageTitle>
|
||||
|
||||
@@ -20,32 +19,29 @@ else
|
||||
<MudStack Spacing="3">
|
||||
|
||||
@* ── Monats-Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
||||
Style="background: linear-gradient(135deg, #3F51B5 0%, #1A237E 100%); color: white;">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
|
||||
Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="PrevMonth" />
|
||||
Style="color:white" Size="Size.Large" OnClick="PrevMonth" />
|
||||
<MudStack Spacing="0" AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700; letter-spacing:0.5px">
|
||||
<MudText Typo="Typo.h5" Style="color:white; font-weight:700; letter-spacing:0.5px">
|
||||
@_deCulture.DateTimeFormat.GetMonthName(_month) @_year
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">
|
||||
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
|
||||
@_subLabel
|
||||
</MudText>
|
||||
</MudStack>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
|
||||
<MudTooltip Text="Monat als Excel exportieren">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Download"
|
||||
Style="color: var(--mud-palette-primary-text)" OnClick="ExportMonthExcel" />
|
||||
</MudTooltip>
|
||||
@if (!IsCurrentMonth)
|
||||
{
|
||||
<MudButton Variant="Variant.Text" Style="color: var(--mud-palette-primary-text)"
|
||||
<MudButton Variant="Variant.Text" Style="color:white"
|
||||
OnClick="GoToCurrentMonth" Size="Size.Small">
|
||||
Heute
|
||||
</MudButton>
|
||||
}
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
|
||||
Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="NextMonth" />
|
||||
Style="color:white" Size="Size.Large" OnClick="NextMonth" />
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
@@ -55,7 +51,7 @@ else
|
||||
<MudCardContent Class="pa-0">
|
||||
<MudSimpleTable Dense="true" Striped="false" Hover="true" Style="overflow-x:auto">
|
||||
<thead>
|
||||
<tr style="background: rgba(14,165,233,0.08);">
|
||||
<tr style="background: rgba(63,81,181,0.08);">
|
||||
<th style="font-weight:700; padding:10px 16px">Tag</th>
|
||||
<th style="font-weight:700; padding:10px 16px">Start</th>
|
||||
<th style="font-weight:700; padding:10px 16px">Ende</th>
|
||||
@@ -99,7 +95,7 @@ else
|
||||
|
||||
@* ── Monatszusammenfassung ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
||||
Style="background: linear-gradient(135deg, color-mix(in srgb, var(--mud-palette-primary) 8%, transparent) 0%, color-mix(in srgb, var(--mud-palette-primary) 2%, transparent) 100%); border-left: 6px solid var(--mud-palette-primary);">
|
||||
Style="background: linear-gradient(135deg, rgba(63,81,181,0.08) 0%, rgba(26,35,126,0.04) 100%); border-left: 6px solid #3F51B5;">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-4">
|
||||
<MudIcon Icon="@Icons.Material.Filled.CalendarViewMonth" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.h6" Style="font-weight:700">Monatszusammenfassung</MudText>
|
||||
@@ -185,15 +181,9 @@ else
|
||||
|
||||
private async Task LoadMonth()
|
||||
{
|
||||
var workDaysTask = TrackerService.GetMonthAsync(_userId, _year, _month);
|
||||
var holidaysTask = HolidayService.GetHolidaysAsync(_year, _settings.GermanState);
|
||||
var vacationsTask = TrackerService.GetVacationDaysAsync(_userId, _year);
|
||||
|
||||
await Task.WhenAll(workDaysTask, holidaysTask, vacationsTask);
|
||||
|
||||
var workDays = await workDaysTask;
|
||||
var holidays = await holidaysTask;
|
||||
var vacations = await vacationsTask;
|
||||
var workDays = await TrackerService.GetMonthAsync(_userId, _year, _month);
|
||||
var holidays = await HolidayService.GetHolidaysAsync(_year, _settings.GermanState);
|
||||
var vacations = await TrackerService.GetVacationDaysAsync(_userId, _year);
|
||||
|
||||
var holidayMap = holidays.ToDictionary(h => h.Date, h => h.Name);
|
||||
var vacationSet = vacations.Select(v => v.Date).ToHashSet();
|
||||
@@ -214,10 +204,7 @@ else
|
||||
.Where(b => b.StartTime.HasValue && b.EndTime.HasValue && b.EndTime > b.StartTime)
|
||||
.Aggregate(TimeSpan.Zero, (s, b) =>
|
||||
s + (b.EndTime!.Value.ToTimeSpan() - b.StartTime!.Value.ToTimeSpan()));
|
||||
var minMinutes = gross.TotalHours >= 9.0 ? 45 : 30;
|
||||
var minBreak = TimeSpan.FromMinutes(Math.Max(minMinutes, _settings.MinimumBreakMinutes));
|
||||
var finalBreak = breaks > minBreak ? breaks : minBreak;
|
||||
net = gross - finalBreak;
|
||||
net = gross - breaks;
|
||||
if (net < TimeSpan.Zero) net = null;
|
||||
}
|
||||
|
||||
@@ -273,11 +260,6 @@ 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;
|
||||
+10
-157
@@ -1,14 +1,10 @@
|
||||
@page "/settings"
|
||||
@rendermode InteractiveWebAssembly
|
||||
@rendermode InteractiveServer
|
||||
@attribute [Authorize]
|
||||
@inject ITimetrackerService TrackerService
|
||||
@inject IHolidayService HolidayService
|
||||
@inject TimetrackerService TrackerService
|
||||
@inject HolidayService HolidayService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject NavigationManager Nav
|
||||
@inject HttpClient Http
|
||||
|
||||
|
||||
<PageTitle>Einstellungen – Timetracker</PageTitle>
|
||||
|
||||
@@ -23,12 +19,13 @@ else
|
||||
<MudStack Spacing="4">
|
||||
|
||||
@* ── Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
||||
Style="background: linear-gradient(135deg, #3F51B5 0%, #1A237E 100%); color:white;">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Settings" Style="color: var(--mud-palette-primary-text); font-size:2rem" />
|
||||
<MudIcon Icon="@Icons.Material.Filled.Settings" Style="color:white; font-size:2rem" />
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700">Einstellungen</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">
|
||||
<MudText Typo="Typo.h5" Style="color:white; font-weight:700">Einstellungen</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
|
||||
Arbeitszeit, Arbeitstage und Urlaub konfigurieren
|
||||
</MudText>
|
||||
</MudStack>
|
||||
@@ -190,79 +187,6 @@ else
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* ── Hilfe & Einführung ── *@
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard Elevation="3" Class="rounded-xl h-100">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.HelpOutline" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.h6" Style="font-weight:600">Hilfe & Einführung</MudText>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudStack Spacing="4">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
Wenn du die interaktive Einführung (Onboarding Tour) noch einmal sehen möchtest, kannst du sie hier zurücksetzen und neu starten.
|
||||
</MudText>
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.PlayCircleOutline"
|
||||
OnClick="RepeatOnboarding"
|
||||
Style="max-width:250px;">
|
||||
Onboarding wiederholen
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@if (_isTenantAdmin)
|
||||
{
|
||||
@* ── Firmen-Einstellungen (Branding) ── *@
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard Elevation="3" Class="rounded-xl h-100">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Business" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.h6" Style="font-weight:600">Firmen-Einstellungen</MudText>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudStack Spacing="4">
|
||||
<MudTextField @bind-Value="_tenantName"
|
||||
Label="Firmenname"
|
||||
Variant="Variant.Outlined"
|
||||
HelperText="Der offizielle Name deiner Firma" />
|
||||
|
||||
<MudStack Row="true" Spacing="4">
|
||||
<MudColorPicker @bind-Text="_tenantPrimaryColor"
|
||||
Label="Primärfarbe"
|
||||
Variant="Variant.Outlined"
|
||||
Style="@($"color: {_tenantPrimaryColor};")"
|
||||
ColorPickerMode="ColorPickerMode.HEX" />
|
||||
|
||||
<MudColorPicker @bind-Text="_tenantSecondaryColor"
|
||||
Label="Sekundärfarbe"
|
||||
Variant="Variant.Outlined"
|
||||
Style="@($"color: {_tenantSecondaryColor};")"
|
||||
ColorPickerMode="ColorPickerMode.HEX" />
|
||||
</MudStack>
|
||||
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Save"
|
||||
OnClick="SaveTenantSettings"
|
||||
Style="max-width:250px;">
|
||||
Firma speichern
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
|
||||
</MudGrid>
|
||||
|
||||
@* ── Speichern-Button ── *@
|
||||
@@ -513,11 +437,6 @@ 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);
|
||||
@@ -576,31 +495,8 @@ else
|
||||
if (claim == null) return;
|
||||
_userId = int.Parse(claim.Value);
|
||||
_settings = await TrackerService.GetSettingsAsync(_userId);
|
||||
|
||||
_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;
|
||||
await LoadVacations();
|
||||
_holHolidays = await HolidayService.GetHolidaysAsync(_holYear, _settings.GermanState);
|
||||
}
|
||||
|
||||
private async Task LoadVacations()
|
||||
@@ -678,49 +574,6 @@ else
|
||||
Snackbar.Add("Feiertag entfernt", Severity.Info);
|
||||
}
|
||||
|
||||
private async Task RepeatOnboarding()
|
||||
{
|
||||
try
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "showOnboarding", "true");
|
||||
Snackbar.Add("Onboarding zurückgesetzt. Leite weiter zur Startseite...", Severity.Info);
|
||||
Nav.NavigateTo("/");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Fehler beim Zurücksetzen des Onboardings: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveTenantSettings()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_tenantName))
|
||||
{
|
||||
Snackbar.Add("Bitte einen Firmennamen eingeben.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var dto = new TenantSettingsDto
|
||||
{
|
||||
Name = _tenantName.Trim(),
|
||||
PrimaryColor = _tenantPrimaryColor,
|
||||
SecondaryColor = _tenantSecondaryColor
|
||||
};
|
||||
|
||||
var response = await Http.PostAsJsonAsync("api/tracker/tenant/settings", dto);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
Snackbar.Add("Firmen-Einstellungen gespeichert. Lade Seite neu...", Severity.Success);
|
||||
await Task.Delay(1000);
|
||||
Nav.NavigateTo(Nav.Uri, forceLoad: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
var err = await response.Content.ReadAsStringAsync();
|
||||
Snackbar.Add($"Fehler beim Speichern: {err}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatHours(double hours)
|
||||
{
|
||||
var ts = TimeSpan.FromHours(hours);
|
||||
+6
-11
@@ -1,8 +1,8 @@
|
||||
@page "/urlaub-maximizer"
|
||||
@rendermode InteractiveWebAssembly
|
||||
@rendermode InteractiveServer
|
||||
@attribute [Authorize]
|
||||
@inject ITimetrackerService TrackerService
|
||||
@inject IHolidayService HolidayService
|
||||
@inject TimetrackerService TrackerService
|
||||
@inject HolidayService HolidayService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
|
||||
@@ -21,7 +21,7 @@ else
|
||||
|
||||
@* ── Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
||||
Style="background: #C2410C; color:white;">
|
||||
Style="background: linear-gradient(135deg, #F57F17 0%, #E65100 100%); color:white;">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
|
||||
Style="color:white" Size="Size.Large" OnClick="PrevYear" />
|
||||
@@ -249,13 +249,8 @@ else
|
||||
|
||||
private async Task LoadYear()
|
||||
{
|
||||
var holidaysTask = HolidayService.GetHolidaysAsync(_year, _settings.GermanState);
|
||||
var vacationsTask = TrackerService.GetVacationDaysAsync(_userId, _year);
|
||||
|
||||
await Task.WhenAll(holidaysTask, vacationsTask);
|
||||
|
||||
var holidays = await holidaysTask;
|
||||
var vacations = await vacationsTask;
|
||||
var holidays = await HolidayService.GetHolidaysAsync(_year, _settings.GermanState);
|
||||
var vacations = await TrackerService.GetVacationDaysAsync(_userId, _year);
|
||||
_holidays = holidays.ToDictionary(h => h.Date, h => h.Name);
|
||||
_vacationSet = vacations.Select(v => v.Date).ToHashSet();
|
||||
_remainingDays = Math.Max(0, _settings.VacationDaysPerYear - vacations.Count);
|
||||
@@ -1,10 +1,10 @@
|
||||
@rendermode InteractiveWebAssembly
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<Router AppAssembly="typeof(timetracker.Client._Imports).Assembly">
|
||||
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
|
||||
<Found Context="routeData">
|
||||
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(timetracker.Client.Components.Layout.MainLayout)">
|
||||
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
|
||||
<NotAuthorized>
|
||||
<timetracker.Client.Components.RedirectToLogin />
|
||||
<RedirectToLogin />
|
||||
</NotAuthorized>
|
||||
</AuthorizeRouteView>
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
@@ -1,4 +1,4 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using System.Security.Claims
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@@ -10,8 +10,7 @@
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using timetracker
|
||||
@using timetracker.Client.Components
|
||||
@using timetracker.Client.Components.Layout
|
||||
@using timetracker.Components
|
||||
@using timetracker.Components.Layout
|
||||
@using timetracker.Data
|
||||
@using timetracker.Shared
|
||||
@using MudBlazor
|
||||
@@ -1,10 +1,9 @@
|
||||
namespace timetracker.Shared;
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class AppSettings
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int UserId { get; set; }
|
||||
public int? TenantId { get; set; }
|
||||
public double DailyTargetHours { get; set; } = 7.5;
|
||||
public int MinimumBreakMinutes { get; set; } = 30;
|
||||
public int VacationDaysPerYear { get; set; } = 30;
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNotificationService notifier)
|
||||
{
|
||||
public async Task<User?> LoginAsync(string username, string password)
|
||||
{
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
var user = await db.Users
|
||||
.FirstOrDefaultAsync(u => u.Username == username);
|
||||
if (user == null) return null;
|
||||
return VerifyPassword(password, user.PasswordHash, user.PasswordSalt) ? user : null;
|
||||
}
|
||||
|
||||
public async Task<List<User>> GetAllUsersAsync()
|
||||
{
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
return await db.Users
|
||||
.OrderBy(u => u.Username)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task DeleteUserAsync(int userId)
|
||||
{
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
var user = await db.Users.FindAsync(userId);
|
||||
if (user != null)
|
||||
{
|
||||
db.Users.Remove(user);
|
||||
await db.SaveChangesAsync();
|
||||
await notifier.NotifyUserDeletedAsync(userId);
|
||||
await notifier.NotifyUsersChangedAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> RenameUserAsync(int userId, string newUsername)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newUsername) || newUsername.Length < 3)
|
||||
return "Benutzername muss mindestens 3 Zeichen lang sein.";
|
||||
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
if (await db.Users.AnyAsync(u => u.Username == newUsername && u.Id != userId))
|
||||
return "Benutzername bereits vergeben.";
|
||||
|
||||
var user = await db.Users.FindAsync(userId);
|
||||
if (user == null) return "Benutzer nicht gefunden.";
|
||||
|
||||
user.Username = newUsername;
|
||||
await db.SaveChangesAsync();
|
||||
await notifier.NotifyUsersChangedAsync();
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<(User? User, string? Error)> RegisterAsync(string username, string password) {
|
||||
if (string.IsNullOrWhiteSpace(username) || username.Length < 3)
|
||||
return (null, "Benutzername muss mindestens 3 Zeichen lang sein.");
|
||||
if (string.IsNullOrWhiteSpace(password) || password.Length < 6)
|
||||
return (null, "Passwort muss mindestens 6 Zeichen lang sein.");
|
||||
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
if (await db.Users.AnyAsync(u => u.Username == username))
|
||||
return (null, "Benutzername bereits vergeben.");
|
||||
|
||||
var (hash, salt) = HashPassword(password);
|
||||
var user = new User { Username = username, PasswordHash = hash, PasswordSalt = salt };
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
await notifier.NotifyUsersChangedAsync();
|
||||
return (user, null);
|
||||
}
|
||||
|
||||
private static (string hash, string salt) HashPassword(string password)
|
||||
{
|
||||
var saltBytes = RandomNumberGenerator.GetBytes(32);
|
||||
var salt = Convert.ToBase64String(saltBytes);
|
||||
var hash = ComputeHash(password, salt);
|
||||
return (hash, salt);
|
||||
}
|
||||
|
||||
private static bool VerifyPassword(string password, string hash, string salt)
|
||||
=> ComputeHash(password, salt) == hash;
|
||||
|
||||
private static string ComputeHash(string password, string salt)
|
||||
{
|
||||
var hash = Rfc2898DeriveBytes.Pbkdf2(
|
||||
Encoding.UTF8.GetBytes(password),
|
||||
Convert.FromBase64String(salt),
|
||||
iterations: 200_000,
|
||||
HashAlgorithmName.SHA256,
|
||||
outputLength: 32);
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
namespace timetracker.Shared;
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class BreakEntry
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int WorkDayId { get; set; }
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public WorkDay WorkDay { get; set; } = null!;
|
||||
public TimeOnly? StartTime { get; set; }
|
||||
public TimeOnly? EndTime { get; set; }
|
||||
@@ -1,11 +1,10 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using timetracker.Shared;
|
||||
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class HolidayService(IDbContextFactory<TimetrackerDbContext> factory, HttpClient http) : IHolidayService
|
||||
public class HolidayService(IDbContextFactory<TimetrackerDbContext> factory, HttpClient http)
|
||||
{
|
||||
private const string ApiUrl = "https://date.nager.at/api/v3/PublicHolidays/{0}/DE";
|
||||
|
||||
+11
-65
@@ -2,7 +2,6 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using timetracker.Data;
|
||||
|
||||
@@ -11,16 +10,14 @@ using timetracker.Data;
|
||||
namespace timetracker.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(TimetrackerDbContext))]
|
||||
[Migration("20260624204954_AddMultiTenancy")]
|
||||
partial class AddMultiTenancy
|
||||
partial class TimetrackerDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.AppSettings", b =>
|
||||
modelBuilder.Entity("timetracker.Data.AppSettings", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -41,9 +38,6 @@ namespace timetracker.Data.Migrations
|
||||
b.Property<int>("MinimumBreakMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -76,7 +70,7 @@ namespace timetracker.Data.Migrations
|
||||
b.ToTable("AppSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
|
||||
modelBuilder.Entity("timetracker.Data.BreakEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -98,7 +92,7 @@ namespace timetracker.Data.Migrations
|
||||
b.ToTable("BreakEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.PublicHoliday", b =>
|
||||
modelBuilder.Entity("timetracker.Data.PublicHoliday", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -119,35 +113,7 @@ namespace timetracker.Data.Migrations
|
||||
b.ToTable("PublicHolidays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.Tenant", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsApproved")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Subdomain")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Subdomain")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Tenants");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.User", b =>
|
||||
modelBuilder.Entity("timetracker.Data.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -161,21 +127,16 @@ namespace timetracker.Data.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.VacationDay", b =>
|
||||
modelBuilder.Entity("timetracker.Data.VacationDay", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -187,9 +148,6 @@ namespace timetracker.Data.Migrations
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -198,7 +156,7 @@ namespace timetracker.Data.Migrations
|
||||
b.ToTable("VacationDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
|
||||
modelBuilder.Entity("timetracker.Data.WorkDay", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -213,9 +171,6 @@ namespace timetracker.Data.Migrations
|
||||
b.Property<TimeOnly?>("StartTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -224,9 +179,9 @@ namespace timetracker.Data.Migrations
|
||||
b.ToTable("WorkDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
|
||||
modelBuilder.Entity("timetracker.Data.BreakEntry", b =>
|
||||
{
|
||||
b.HasOne("timetracker.Shared.WorkDay", "WorkDay")
|
||||
b.HasOne("timetracker.Data.WorkDay", "WorkDay")
|
||||
.WithMany("Breaks")
|
||||
.HasForeignKey("WorkDayId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
@@ -235,16 +190,7 @@ namespace timetracker.Data.Migrations
|
||||
b.Navigation("WorkDay");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.User", b =>
|
||||
{
|
||||
b.HasOne("timetracker.Shared.Tenant", "Tenant")
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId");
|
||||
|
||||
b.Navigation("Tenant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
|
||||
modelBuilder.Entity("timetracker.Data.WorkDay", b =>
|
||||
{
|
||||
b.Navigation("Breaks");
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace timetracker.Shared;
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class PublicHoliday
|
||||
{
|
||||
@@ -0,0 +1,13 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class TimetrackerDbContext(DbContextOptions<TimetrackerDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<User> Users => Set<User>();
|
||||
public DbSet<WorkDay> WorkDays => Set<WorkDay>();
|
||||
public DbSet<BreakEntry> BreakEntries => Set<BreakEntry>();
|
||||
public DbSet<AppSettings> AppSettings => Set<AppSettings>();
|
||||
public DbSet<VacationDay> VacationDays => Set<VacationDay>();
|
||||
public DbSet<PublicHoliday> PublicHolidays => Set<PublicHoliday>();
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using timetracker.Shared;
|
||||
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class TimetrackerService(IDbContextFactory<TimetrackerDbContext> factory) : ITimetrackerService
|
||||
public class TimetrackerService(IDbContextFactory<TimetrackerDbContext> factory)
|
||||
{
|
||||
public async Task<List<WorkDay>> GetWeekAsync(int userId, DateOnly monday)
|
||||
{
|
||||
@@ -173,10 +172,7 @@ public class TimetrackerService(IDbContextFactory<TimetrackerDbContext> factory)
|
||||
.Where(b => b.StartTime.HasValue && b.EndTime.HasValue && b.EndTime > b.StartTime)
|
||||
.Aggregate(TimeSpan.Zero, (s, b) =>
|
||||
s + (b.EndTime!.Value.ToTimeSpan() - b.StartTime!.Value.ToTimeSpan()));
|
||||
var minMinutes = gross.TotalHours >= 9.0 ? 45 : 30;
|
||||
var minBreak = TimeSpan.FromMinutes(Math.Max(minMinutes, settings.MinimumBreakMinutes));
|
||||
var finalBreak = breakTotal > minBreak ? breakTotal : minBreak;
|
||||
actual = (gross - finalBreak).TotalHours;
|
||||
actual = (gross - breakTotal).TotalHours;
|
||||
if (actual < 0) actual = 0.0;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace timetracker.Shared;
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class User
|
||||
{
|
||||
@@ -6,8 +6,4 @@ public class User
|
||||
public string Username { get; set; } = "";
|
||||
public string PasswordHash { get; set; } = "";
|
||||
public string PasswordSalt { get; set; } = "";
|
||||
|
||||
public int? TenantId { get; set; }
|
||||
public Tenant? Tenant { get; set; }
|
||||
public bool IsTenantAdmin { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class UserNotificationService
|
||||
{
|
||||
public event Func<Task>? OnUsersChanged;
|
||||
public event Func<int, Task>? OnUserDeleted;
|
||||
|
||||
public async Task NotifyUsersChangedAsync()
|
||||
{
|
||||
if (OnUsersChanged != null)
|
||||
await OnUsersChanged.Invoke();
|
||||
}
|
||||
|
||||
public async Task NotifyUserDeletedAsync(int userId)
|
||||
{
|
||||
if (OnUserDeleted != null)
|
||||
await OnUserDeleted.Invoke(userId);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
namespace timetracker.Shared;
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class VacationDay
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int UserId { get; set; }
|
||||
public int? TenantId { get; set; }
|
||||
public DateOnly Date { get; set; }
|
||||
public string? Note { get; set; }
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
namespace timetracker.Shared;
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class WorkDay
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int UserId { get; set; }
|
||||
public int? TenantId { get; set; }
|
||||
public DateOnly Date { get; set; }
|
||||
public TimeOnly? StartTime { get; set; }
|
||||
public TimeOnly? EndTime { get; set; }
|
||||
@@ -2,22 +2,11 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copy project files for restoring dependencies
|
||||
COPY timetracker.slnx ./
|
||||
COPY timetracker.Server/timetracker.Server.csproj timetracker.Server/
|
||||
COPY timetracker.Client/timetracker.Client.csproj timetracker.Client/
|
||||
COPY timetracker.Shared/timetracker.Shared.csproj timetracker.Shared/
|
||||
# Keine Unterordner mehr beim Kopieren!
|
||||
COPY timetracker.csproj ./
|
||||
RUN dotnet restore timetracker.csproj
|
||||
|
||||
# Restore dependencies
|
||||
RUN dotnet restore timetracker.slnx
|
||||
|
||||
# Copy the rest of the source code
|
||||
COPY timetracker.Server/ timetracker.Server/
|
||||
COPY timetracker.Client/ timetracker.Client/
|
||||
COPY timetracker.Shared/ timetracker.Shared/
|
||||
|
||||
# Publish
|
||||
WORKDIR /src/timetracker.Server
|
||||
COPY . ./
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
# ── Runtime Stage ─────────────────────────────────────────────────────────────
|
||||
@@ -25,7 +14,7 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
# Directory for SQLite database
|
||||
# Verzeichnis für die SQLite-Datenbank
|
||||
RUN mkdir -p /data
|
||||
|
||||
ENV ASPNETCORE_HTTP_PORTS=8080
|
||||
@@ -38,4 +27,4 @@ EXPOSE 8080
|
||||
|
||||
VOLUME ["/data"]
|
||||
|
||||
ENTRYPOINT ["dotnet", "timetracker.Server.dll"]
|
||||
ENTRYPOINT ["dotnet", "timetracker.dll"]
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MudBlazor.Services;
|
||||
using timetracker.Components;
|
||||
using timetracker.Data;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(options =>
|
||||
{
|
||||
options.LoginPath = "/login";
|
||||
options.LogoutPath = "/auth/logout";
|
||||
options.ExpireTimeSpan = TimeSpan.FromDays(30);
|
||||
options.SlidingExpiration = true;
|
||||
});
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy("AdminOnly", policy =>
|
||||
policy.RequireClaim(System.Security.Claims.ClaimTypes.Name, "marc"));
|
||||
});
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddSingleton<timetracker.Data.UserNotificationService>();
|
||||
builder.Services.AddScoped<AuthService>();
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
builder.Services.AddMudServices();
|
||||
builder.Services.AddHttpClient<HolidayService>();
|
||||
|
||||
var dbPath = Environment.GetEnvironmentVariable("TIMETRACKER_DB_PATH")
|
||||
?? Path.Combine(builder.Environment.ContentRootPath, "timetracker.db");
|
||||
builder.Services.AddDbContextFactory<TimetrackerDbContext>(options =>
|
||||
options.UseSqlite($"Data Source={dbPath}"));
|
||||
builder.Services.AddScoped<TimetrackerService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var factory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<TimetrackerDbContext>>();
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
var forwardedHeadersOptions = new ForwardedHeadersOptions
|
||||
{
|
||||
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
|
||||
};
|
||||
forwardedHeadersOptions.KnownProxies.Clear();
|
||||
forwardedHeadersOptions.KnownIPNetworks.Clear();
|
||||
app.UseForwardedHeaders(forwardedHeadersOptions);
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
|
||||
app.UseHsts();
|
||||
}
|
||||
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
|
||||
if (app.Configuration.GetValue("EnableHttpsRedirect", !app.Environment.IsDevelopment()))
|
||||
{
|
||||
app.UseHttpsRedirection();
|
||||
}
|
||||
|
||||
// Statische Dateien (inkl. _framework/, _content/) vor Auth bedienen,
|
||||
// damit Blazor-JS und MudBlazor-CSS nie durch Auth-Middleware geblockt werden
|
||||
app.UseStaticFiles();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.UseAntiforgery();
|
||||
|
||||
app.MapStaticAssets();
|
||||
app.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
|
||||
// ── Auth-Endpoints ────────────────────────────────────────────────────────────
|
||||
app.MapPost("/auth/login", async (HttpContext ctx, AuthService authService) =>
|
||||
{
|
||||
var form = await ctx.Request.ReadFormAsync();
|
||||
var username = form["username"].ToString();
|
||||
var password = form["password"].ToString();
|
||||
var user = await authService.LoginAsync(username, password);
|
||||
if (user == null)
|
||||
return Results.Redirect("/login?error=invalid");
|
||||
|
||||
var claims = new[] {
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, user.Username)
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
new ClaimsPrincipal(identity),
|
||||
new AuthenticationProperties { IsPersistent = true });
|
||||
return Results.Redirect("/");
|
||||
}).DisableAntiforgery();
|
||||
|
||||
app.MapPost("/auth/register", async (HttpContext ctx, AuthService authService) =>
|
||||
{
|
||||
var form = await ctx.Request.ReadFormAsync();
|
||||
var username = form["username"].ToString();
|
||||
var password = form["password"].ToString();
|
||||
var (user, error) = await authService.RegisterAsync(username, password);
|
||||
if (user == null)
|
||||
return Results.Redirect($"/login?tab=register&error={Uri.EscapeDataString(error!)}");
|
||||
|
||||
var claims = new[] {
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, user.Username)
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
new ClaimsPrincipal(identity),
|
||||
new AuthenticationProperties { IsPersistent = true });
|
||||
return Results.Redirect("/");
|
||||
}).DisableAntiforgery();
|
||||
|
||||
app.MapGet("/auth/logout", async (HttpContext ctx) =>
|
||||
{
|
||||
await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
return Results.Redirect("/login");
|
||||
});
|
||||
|
||||
app.Run();
|
||||
+6
-25
@@ -1,41 +1,22 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: timetracker-db
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: timetracker_user
|
||||
POSTGRES_PASSWORD: SecretPassword123
|
||||
POSTGRES_DB: timetracker
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
timetracker:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: timetracker.Server/Dockerfile
|
||||
dockerfile: Dockerfile
|
||||
container_name: timetracker-app
|
||||
restart: always
|
||||
ports:
|
||||
- "8090:8080"
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Production
|
||||
- ASPNETCORE_HTTP_PORTS=8080
|
||||
- DB_PROVIDER=PostgreSQL
|
||||
- ConnectionStrings__DefaultConnection=Host=db;Database=timetracker;Username=timetracker_user;Password=SecretPassword123;
|
||||
- ASPNETCORE_HTTP_PORTS=8080 # Lassen wir so, da intern völlig okay
|
||||
- TIMETRACKER_DB_PATH=/data/timetracker.db
|
||||
- EnableHttpsRedirect=false
|
||||
- TenantSettings__BaseDomain=timetracker.marc-wieland.de
|
||||
volumes:
|
||||
- dpkeys:/home/app/.aspnet/DataProtection-Keys
|
||||
depends_on:
|
||||
- db
|
||||
- timetracker_data:/data
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
name: timetracker_pgdata
|
||||
dpkeys:
|
||||
name: timetracker_dpkeys
|
||||
timetracker_data:
|
||||
name: timetracker_prod_data
|
||||
@@ -1,73 +0,0 @@
|
||||
# Brainstorming & Zukunfts-Ideen für den Timetracker
|
||||
|
||||
Der Timetracker ist durch die Umstellung auf eine Hosted Blazor WebAssembly (.NET 10) Architektur mit PostgreSQL-Unterstützung extrem solide und zukunftssicher aufgestellt. Hier sind einige spannende, innovative Produktideen, um das Tool auf das nächste Level zu heben:
|
||||
|
||||
---
|
||||
|
||||
## 🚀 1. PWA-Support (Progressive Web App) und Offline-Modus
|
||||
* **Offline-Erfassung**: Da die App in Blazor WebAssembly im Browser läuft, lässt sie sich leicht zu einer PWA umrüsten. Zeiterfassungen könnten lokal im `IndexedDB`-Speicher des Browsers gesichert werden, wenn keine Internetverbindung besteht, und bei Wiederverbindung automatisch mit der PostgreSQL-Datenbank synchronisiert werden.
|
||||
* **Mobiles App-Feeling**: Installation direkt auf dem Homescreen (Android/iOS) ohne App-Store-Zwang.
|
||||
* **Web-Push Notifications**: Tägliche Erinnerungen, falls man vergisst, sich auszustempeln oder Pausen einzutragen.
|
||||
|
||||
---
|
||||
|
||||
## 🤖 2. KI-Assistent & Smart-Suggestions
|
||||
* **Kalender- & Git-Sync**: Einbindung einer KI-Schnittstelle, die Zugriff auf Terminkalender (Google/Outlook) oder lokale Git-Commits hat. Sie schlägt am Ende des Tages automatisch passende Buchungen vor (z. B. *"Du hast von 10-11 Uhr am Feature X gearbeitet. Möchtest du das buchen?"*).
|
||||
* **Ausreißer-Erkennung**: KI-gestützte Warnungen, wenn Zeiten untypisch erfasst wurden (z. B. 12 Stunden gearbeitet ohne Pause).
|
||||
|
||||
---
|
||||
|
||||
## 📅 3. Kalender-Integration & Feiertags-Automatisierung
|
||||
* **Direktes Feiertags-Abonnement**: Automatische Erkennung des Bundeslandes und Import der Feiertage ohne manuellen Klick auf "Von API laden" zu Beginn des Jahres.
|
||||
* **Kalender-Export (iCal)**: Generierung eines persönlichen iCal-Feeds, damit der User seine gebuchten Urlaubstage und Überstunden in seinem primären Kalender (Google Calendar, Apple Calendar) sieht.
|
||||
|
||||
---
|
||||
|
||||
## 👥 4. Team-Features & Urlaubsplaner
|
||||
* **Wer-ist-da-Dashboard**: Ein Live-Statusboard für Teams, auf dem man sieht, wer gerade eingestempelt ist, wer Pause macht oder wer heute Urlaub hat/krankgeschrieben ist.
|
||||
* **Gemeinsamer Urlaubskalender**: Ein Kalender für das gesamte Team, um Urlaubsüberschneidungen direkt zu erkennen und Freigabeprozesse durch Admins zu ermöglichen.
|
||||
* **Rollenkonzepte**: Einführung von Teamleitern (können Zeiten der Mitglieder freigeben) und Standard-Mitarbeitern.
|
||||
|
||||
---
|
||||
|
||||
## 📊 5. Reporting & Abrechnung (Billing)
|
||||
* **Rechnungserstellung**: Verknüpfung von erfassten Zeiten mit Stundensätzen, um mit einem Klick professionelle PDF-Rechnungen an Kunden zu erstellen.
|
||||
* **Auswertungen**: Interaktive Diagramme (z.B. mittels MudBlazor Charts) zur Analyse von Arbeitszeiten, Überstundenentwicklung und Krankheitstagen über das Jahr verteilt.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 6. Integrationen (Jira, GitHub, Slack)
|
||||
* **Git-Hooks / CLI**: Automatisches Starten/Stoppen von Timern per CLI oder bei Git-Commits.
|
||||
* **Slack / Teams Chatbot**: Schnelles Ein- und Ausstempeln direkt aus dem Chat-Client heraus über Befehle wie `/track start` oder `/track pause`.
|
||||
|
||||
---
|
||||
|
||||
## 🔒 7. Sicherheit & DSGVO
|
||||
* **Audit Logs**: Transparente Historie darüber, wer wann welche Zeiten geändert hat (besonders wichtig bei Prüfungen).
|
||||
* **Automatisches Löschkonzept**: Möglichkeit zur Einhaltung von Löschfristen erfasster Daten nach Beendigung des Arbeitsverhältnisses.
|
||||
|
||||
---
|
||||
|
||||
## ✉️ 8. E-Mail-Verifizierung & Inhouse-Mailserver
|
||||
* **Sicherer Registrierungs-Flow**: E-Mail-Verifizierung bei Neuregistrierung mittels eines 6-stelligen, zeitlich begrenzten Bestätigungscodes. Erst nach Eingabe des Codes wird das Benutzerkonto aktiviert und der Login erlaubt.
|
||||
* **Inhouse SMTP-Lösungen**:
|
||||
* **Lokale Entwicklung**: Einbindung eines SMTP-Sandboxes wie **Mailpit** (oder MailHog) direkt in die `docker-compose.yml`, um E-Mails sicher lokal zu fangen und im Browser zu sichten.
|
||||
* **Produktion**: Konfiguration für inhouse SMTP-Relays oder Aufbau eines vollverschlüsselten **docker-mailserver** Containers für den komplett eigenständigen, datenschutzkonformen E-Mail-Versand.
|
||||
|
||||
---
|
||||
|
||||
## 🏢 9. B2B-Fähigkeit & Azure AD (Entra ID) Integration
|
||||
* **Generisches Single Sign-On (SSO)**: Integration von OpenID Connect (OIDC) über Umgebungsvariablen. Dadurch können Unternehmen das Tool nahtlos an Microsoft Entra ID (Azure AD), Okta, Keycloak oder Google Workspace anbinden.
|
||||
* **White-Labeling & Customizing (Single-Tenant)**: Möglichkeit, Firmenlogos, Firmennamen und Standardeinstellungen (z.B. Feiertags-Bundesland) pro Docker-Compose-Instanz flexibel anzupassen.
|
||||
* **Optionale SaaS-Erweiterung (Multi-Tenant)**: Zukünftiger Ausbau für mehrmandantenfähigen Betrieb mit Datenisolation auf Tabellenebene (`TenantId`), dynamischer SSO-Zuordnung per E-Mail-Domain und zentraler Rechnungsabwicklung (z.B. Stripe).
|
||||
|
||||
---
|
||||
|
||||
## 💬 10. Gamifizierter Assistent & KI-Chatbot
|
||||
* **Regelbasierter Gamification-Widget (Quick-Win)**: Ein kleiner, charismatischer Assistent/Avatar im UI, der basierend auf den aktuellen Arbeitsstatistiken motivierende, freche oder informative Sprüche anzeigt (z.B. bei vielen Überstunden, fehlenden Pausen oder anstehendem Urlaub).
|
||||
* **LLM-gestützter KI-Assistent (Smart-Assistant)**:
|
||||
* **Analysen**: Ein Chat-Fenster, in dem der Benutzer Fragen zu seinen Arbeitszeiten stellen kann (z.B. *"Wie viele Überstunden habe ich diesen Monat angesammelt?"* oder *"Wann habe ich letzte Woche Dienstag angefangen?"*).
|
||||
* **Interaktion & Aktionen**: Die KI kann durch Function Calling direkt Aktionen ausführen (z.B. *"Trage mir für morgen 8 Stunden Arbeit ein"* oder *"Trage Urlaub für KW 28 ein"*).
|
||||
* **Technologie**: Anbindung über ein einfaches HttpClient-Modul an die Google Gemini API, OpenAI oder lokal über ein Ollama-Docker-Container.
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Exit on error
|
||||
set -e
|
||||
|
||||
echo "=== 1. Bereinige alte Build-Dateien ==="
|
||||
dotnet clean
|
||||
|
||||
echo "=== 2. Baue die gesamte Solution ==="
|
||||
dotnet build
|
||||
|
||||
echo "=== 3. Starte den Server & öffne Browser ==="
|
||||
# Browser parallel nach kurzem Delay öffnen
|
||||
(sleep 3 && open http://localhost:5065) &
|
||||
|
||||
dotnet run --project timetracker.Server/timetracker.Server.csproj
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 61 KiB |
@@ -1,297 +0,0 @@
|
||||
@inject ITimetrackerService TrackerService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject IJSRuntime JS
|
||||
@using System.Security.Claims
|
||||
@using timetracker.Shared
|
||||
|
||||
@if (_isOpen)
|
||||
{
|
||||
<div class="timebot-chat-window">
|
||||
<!-- Chat Header -->
|
||||
<div class="timebot-header">
|
||||
<div class="timebot-header-info">
|
||||
<img src="/timebot.png" class="timebot-header-avatar" alt="Timebot" />
|
||||
<div class="timebot-header-text">
|
||||
<span class="timebot-header-title">Timebot</span>
|
||||
<span class="timebot-header-status"><span class="status-dot"></span> Online</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="timebot-close-btn" @onclick="ToggleChat" aria-label="Schließen">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Close" Size="Size.Small" Style="color: white;" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Chat Body -->
|
||||
<div class="timebot-body" id="timebot-body-el">
|
||||
@foreach (var msg in _messages)
|
||||
{
|
||||
<div class="timebot-msg-row @(msg.Sender == "User" ? "user-row" : "bot-row")">
|
||||
@if (msg.Sender == "Bot")
|
||||
{
|
||||
<img src="/timebot.png" class="timebot-msg-avatar" alt="Timebot" />
|
||||
}
|
||||
<div class="timebot-msg-bubble @(msg.Sender == "User" ? "user-bubble" : "bot-bubble")">
|
||||
@if (msg.IsTyping)
|
||||
{
|
||||
<div class="typing-indicator">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@((MarkupString)FormatMessageText(msg.Content))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Suggestion Chips -->
|
||||
<div class="timebot-suggestions">
|
||||
<button @onclick="@(() => AskQuestion("Überstunden"))">📊 Überstunden</button>
|
||||
<button @onclick="@(() => AskQuestion("Woche"))">⏱️ Wochenarbeitszeit</button>
|
||||
<button @onclick="@(() => AskQuestion("Urlaub"))">🏖️ Urlaubs-Maximizer</button>
|
||||
<button @onclick="@(() => AskQuestion("Hilfe"))">💡 Hilfe</button>
|
||||
</div>
|
||||
|
||||
<!-- Chat Footer -->
|
||||
<div class="timebot-footer">
|
||||
<input type="text" @bind="_userInput" @bind:event="oninput" @onkeypress="HandleKeyPress" placeholder="Schreibe eine Nachricht..." class="timebot-input" />
|
||||
<button class="timebot-send-btn" @onclick="SendMessage" disabled="@(string.IsNullOrWhiteSpace(_userInput))">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Send" Style="color: #3F51B5;" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Floating Button -->
|
||||
<div class="timebot-launcher @(_isOpen ? "open" : "")" @onclick="ToggleChat">
|
||||
<img src="/timebot.png" alt="Timebot Launcher" />
|
||||
@if (_unreadCount > 0 && !_isOpen)
|
||||
{
|
||||
<span class="timebot-badge">@_unreadCount</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private bool _isOpen = false;
|
||||
private string _userInput = "";
|
||||
private int _unreadCount = 1;
|
||||
private List<ChatMessage> _messages = new();
|
||||
|
||||
private int _userId;
|
||||
private string _username = "Marc";
|
||||
private AppSettings _settings = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
|
||||
if (claim != null)
|
||||
{
|
||||
_userId = int.Parse(claim.Value);
|
||||
_username = authState.User.Identity?.Name ?? "Marc";
|
||||
if (!string.IsNullOrEmpty(_username))
|
||||
{
|
||||
_username = char.ToUpper(_username[0]) + _username.Substring(1);
|
||||
}
|
||||
_settings = await TrackerService.GetSettingsAsync(_userId);
|
||||
}
|
||||
|
||||
_messages.Add(new ChatMessage
|
||||
{
|
||||
Sender = "Bot",
|
||||
Content = $"Hallo {_username}! 👋 Ich bin dein Timebot. Ich habe ein Auge auf deine Zeiten geworfen.\n\nWie kann ich dir heute helfen?",
|
||||
Timestamp = DateTime.Now
|
||||
});
|
||||
}
|
||||
|
||||
private void ToggleChat()
|
||||
{
|
||||
_isOpen = !_isOpen;
|
||||
if (_isOpen)
|
||||
{
|
||||
_unreadCount = 0;
|
||||
_ = ScrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleKeyPress(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_userInput))
|
||||
{
|
||||
await SendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendMessage()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_userInput)) return;
|
||||
var text = _userInput;
|
||||
_userInput = "";
|
||||
|
||||
_messages.Add(new ChatMessage { Sender = "User", Content = text, Timestamp = DateTime.Now });
|
||||
await ScrollToBottom();
|
||||
|
||||
await RespondTo(text);
|
||||
}
|
||||
|
||||
private async Task AskQuestion(string key)
|
||||
{
|
||||
string userQuestion = "";
|
||||
if (key == "Überstunden") userQuestion = "Wie viele Überstunden habe ich?";
|
||||
else if (key == "Woche") userQuestion = "Wie sieht meine Woche aus?";
|
||||
else if (key == "Urlaub") userQuestion = "Wie hilft mir der Urlaubs-Maximizer?";
|
||||
else if (key == "Hilfe") userQuestion = "Welche Fragen kann ich dir stellen?";
|
||||
|
||||
_messages.Add(new ChatMessage { Sender = "User", Content = userQuestion, Timestamp = DateTime.Now });
|
||||
await ScrollToBottom();
|
||||
|
||||
await RespondTo(key);
|
||||
}
|
||||
|
||||
private async Task RespondTo(string query)
|
||||
{
|
||||
// Add typing indicator
|
||||
var typingMsg = new ChatMessage { Sender = "Bot", IsTyping = true, Timestamp = DateTime.Now };
|
||||
_messages.Add(typingMsg);
|
||||
await ScrollToBottom();
|
||||
|
||||
// Simulate thinking/typing delay
|
||||
await Task.Delay(1000);
|
||||
_messages.Remove(typingMsg);
|
||||
|
||||
string responseText = "";
|
||||
var normalized = query.ToLower();
|
||||
|
||||
if (normalized.Contains("überstunden") || normalized == "überstunden" || normalized.Contains("saldo") || normalized.Contains("gleitzeit"))
|
||||
{
|
||||
var settings = await TrackerService.GetSettingsAsync(_userId);
|
||||
var overtime = await TrackerService.GetTotalOvertimeAsync(_userId, settings);
|
||||
var overtimeStr = FormatTs(overtime, sign: true);
|
||||
|
||||
if (overtime >= TimeSpan.Zero)
|
||||
{
|
||||
responseText = $"Dein Gleitzeitkonto sieht super aus! Du hast aktuell **{overtimeStr} Std.** auf deinem Gleitzeitkonto angesammelt.\n\nSehr stark! Hast du mit der Extrazeit schon etwas vor oder bist du einfach nur voll am Hustlen? 🚀🔥";
|
||||
}
|
||||
else
|
||||
{
|
||||
responseText = $"Dein Gleitzeitkonto steht aktuell bei **{overtimeStr} Std.**\n\nDu bist also ein kleines bisschen im Minus. Aber kein Grund zur Sorge: Das lässt sich mit ein paar Extra-Minuten in den nächsten Tagen leicht wieder ausgleichen! 💪";
|
||||
}
|
||||
}
|
||||
else if (normalized.Contains("woche") || normalized == "woche" || normalized.Contains("arbeitszeit") || normalized.Contains("stunden"))
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var monday = GetMonday(today);
|
||||
var settings = await TrackerService.GetSettingsAsync(_userId);
|
||||
var weekDays = await TrackerService.GetWeekAsync(_userId, monday);
|
||||
|
||||
double workedHours = 0;
|
||||
double targetHours = 0;
|
||||
|
||||
foreach (var date in Enumerable.Range(0, 7).Select(i => monday.AddDays(i)))
|
||||
{
|
||||
bool isWorkDay = settings.IsWorkDay(date.DayOfWeek);
|
||||
if (isWorkDay)
|
||||
{
|
||||
targetHours += settings.DailyTargetHours;
|
||||
}
|
||||
|
||||
var wd = weekDays.FirstOrDefault(d => d.Date == date);
|
||||
if (wd != null && wd.StartTime != null && wd.EndTime != null)
|
||||
{
|
||||
var gross = wd.EndTime.Value.ToTimeSpan() - wd.StartTime.Value.ToTimeSpan();
|
||||
if (gross > TimeSpan.Zero)
|
||||
{
|
||||
var breakTotal = wd.Breaks
|
||||
.Where(b => b.StartTime.HasValue && b.EndTime.HasValue && b.EndTime > b.StartTime)
|
||||
.Aggregate(TimeSpan.Zero, (s, b) =>
|
||||
s + (b.EndTime!.Value.ToTimeSpan() - b.StartTime!.Value.ToTimeSpan()));
|
||||
workedHours += (gross - breakTotal).TotalHours;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var workedTs = TimeSpan.FromHours(workedHours);
|
||||
var targetTs = TimeSpan.FromHours(targetHours);
|
||||
var percent = targetHours > 0 ? (int)Math.Round(workedHours / targetHours * 100) : 0;
|
||||
|
||||
responseText = $"Diese Woche hast du bereits **{FormatTs(workedTs)}** von geplanten **{FormatTs(targetTs)}** gearbeitet.\n\nDu hast diese Woche also schon **{percent}%** deines Solls erfüllt. Weiter so! ⏰";
|
||||
}
|
||||
else if (normalized.Contains("urlaub") || normalized == "urlaub" || normalized.Contains("maximizer") || normalized.Contains("brückentag"))
|
||||
{
|
||||
responseText = $"Der **Urlaubs-Maximizer** analysiert das Kalenderjahr, gesetzliche Feiertage und Brückentage, um dir die besten Zeiträume für Urlaub vorzuschlagen.\n\nDu findest die Auswertung direkt in der Sidebar unter **'Urlaubs-Maximizer'**. So machst du aus wenigen Urlaubstagen maximale Freizeit! 🏖️✈️";
|
||||
}
|
||||
else if (normalized.Contains("hilfe") || normalized == "hilfe" || normalized.Contains("hallo") || normalized.Contains("hi") || normalized.Contains("helo") || normalized.Contains("huhu") || normalized == "hilfe")
|
||||
{
|
||||
responseText = $"Ich kann dir Auskunft über deine Zeiten geben. Frage mich zum Beispiel:\n\n" +
|
||||
$"• *'Wie viele Überstunden habe ich?'*\n" +
|
||||
$"• *'Wie sieht meine Woche aus?'*\n" +
|
||||
$"• *'Was macht der Urlaubs-Maximizer?'*\n\n" +
|
||||
$"Oder tippe einfach deine Frage ein! 💡";
|
||||
}
|
||||
else
|
||||
{
|
||||
responseText = $"Entschuldige, diese Frage kann ich leider noch nicht beantworten. 🤖\n\nFrage mich gerne nach deinem **Überstunden-Saldo**, deiner **Wochen-Arbeitszeit** oder dem **Urlaubs-Maximizer**!";
|
||||
}
|
||||
|
||||
_messages.Add(new ChatMessage { Sender = "Bot", Content = responseText, Timestamp = DateTime.Now });
|
||||
await ScrollToBottom();
|
||||
}
|
||||
|
||||
private async Task ScrollToBottom()
|
||||
{
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("eval", "setTimeout(() => { const el = document.getElementById('timebot-body-el'); if(el) el.scrollTop = el.scrollHeight; }, 50);");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Prerender-Pass ignorieren
|
||||
}
|
||||
}
|
||||
|
||||
private string FormatMessageText(string text)
|
||||
{
|
||||
// Simple helper to replace markdown bold (**text**) with HTML bold (<strong>text</strong>)
|
||||
// and newlines (\n) with <br />
|
||||
if (string.IsNullOrEmpty(text)) return "";
|
||||
|
||||
var formatted = text;
|
||||
|
||||
// Escape HTML first to prevent XSS
|
||||
formatted = System.Net.WebUtility.HtmlEncode(formatted);
|
||||
|
||||
// Replace back escaped ** formatting
|
||||
// Simple regex replace for **text**
|
||||
var regex = new System.Text.RegularExpressions.Regex(@"\*\*(.*?)\*\*");
|
||||
formatted = regex.Replace(formatted, "<strong>$1</strong>");
|
||||
|
||||
// Replace newlines
|
||||
formatted = formatted.Replace("\n", "<br />");
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
private static DateOnly GetMonday(DateOnly date)
|
||||
{
|
||||
int diff = ((int)date.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7;
|
||||
return date.AddDays(-diff);
|
||||
}
|
||||
|
||||
private static string FormatTs(TimeSpan ts, bool sign = false)
|
||||
{
|
||||
if (ts == TimeSpan.Zero && sign) return "±0:00";
|
||||
var prefix = sign ? (ts >= TimeSpan.Zero ? "+" : "−") : (ts < TimeSpan.Zero ? "−" : "");
|
||||
var abs = ts.Duration();
|
||||
return $"{prefix}{(int)abs.TotalHours}:{abs.Minutes:D2}";
|
||||
}
|
||||
|
||||
private sealed class ChatMessage
|
||||
{
|
||||
public string Sender { get; set; } = "Bot";
|
||||
public string Content { get; set; } = "";
|
||||
public DateTime Timestamp { get; set; } = DateTime.Now;
|
||||
public bool IsTyping { get; set; } = false;
|
||||
}
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
/* Floating Launcher Button */
|
||||
.timebot-launcher {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f1f3f9 100%);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
cursor: pointer;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
border: 2px solid #0EA5E9;
|
||||
}
|
||||
|
||||
.timebot-launcher:hover {
|
||||
transform: scale(1.1) translateY(-4px);
|
||||
box-shadow: 0 12px 28px rgba(14, 165, 233, 0.4);
|
||||
border-color: #0284C7;
|
||||
}
|
||||
|
||||
.timebot-launcher img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.timebot-launcher.open {
|
||||
transform: scale(0.9) rotate(90deg);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.timebot-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
background-color: #ff3b30;
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
padding: 3px 7px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
/* Chat Window */
|
||||
.timebot-chat-window {
|
||||
position: fixed;
|
||||
bottom: 100px;
|
||||
right: 24px;
|
||||
width: 360px;
|
||||
height: 520px;
|
||||
max-height: 80vh;
|
||||
border-radius: 20px;
|
||||
background: rgba(32, 33, 36, 0.95);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: 9999;
|
||||
animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
color: #f1f3f9;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.timebot-header {
|
||||
background: #0F172A;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.timebot-header-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.timebot-header-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 50%;
|
||||
padding: 2px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.timebot-header-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.timebot-header-title {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
color: white;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.timebot-header-status {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: #4cd964;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
box-shadow: 0 0 8px #4cd964;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(76, 217, 100, 0.7); }
|
||||
70% { transform: scale(1); box-shadow: 0 0 0 6px rgba(76, 217, 100, 0); }
|
||||
100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(76, 217, 100, 0); }
|
||||
}
|
||||
|
||||
.timebot-close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.timebot-close-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Chat Body */
|
||||
.timebot-body {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
background: rgba(24, 25, 28, 0.4);
|
||||
}
|
||||
|
||||
.timebot-msg-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.timebot-msg-row.bot-row {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.timebot-msg-row.user-row {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.timebot-msg-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
padding: 2px;
|
||||
align-self: flex-end;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.timebot-msg-bubble {
|
||||
padding: 10px 14px;
|
||||
border-radius: 16px;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.bot-bubble {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #e3e3e7;
|
||||
border-bottom-left-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.user-bubble {
|
||||
background: linear-gradient(135deg, #0EA5E9 0%, #0284C7 100%);
|
||||
color: white;
|
||||
border-bottom-right-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(14, 165, 233, 0.2);
|
||||
}
|
||||
|
||||
/* Typing Indicator */
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: #a8b2c1;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
animation: bounce 1.4s infinite both;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
|
||||
.typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% { transform: scale(0); }
|
||||
40% { transform: scale(1.0); }
|
||||
}
|
||||
|
||||
/* Suggestions */
|
||||
.timebot-suggestions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(18, 18, 20, 0.6);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none; /* Scrollbar hidden for visual aesthetics */
|
||||
}
|
||||
|
||||
.timebot-suggestions::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timebot-suggestions button {
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: #cbd5e1;
|
||||
padding: 6px 12px;
|
||||
border-radius: 14px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.timebot-suggestions button:hover {
|
||||
background: rgba(14, 165, 233, 0.15);
|
||||
border-color: #0EA5E9;
|
||||
color: white;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.timebot-footer {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(32, 33, 36, 0.98);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.timebot-input {
|
||||
flex: 1;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: white;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.timebot-input:focus {
|
||||
border-color: #0EA5E9;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 0 0 2px rgba(14, 165, 233, 0.2);
|
||||
}
|
||||
|
||||
.timebot-send-btn {
|
||||
background: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.timebot-send-btn:hover {
|
||||
background: #e0f2fe;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.timebot-send-btn:disabled {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.timebot-send-btn:disabled ::deep .mud-icon-root {
|
||||
color: rgba(255, 255, 255, 0.2) !important;
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
@inherits LayoutComponentBase
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Nav
|
||||
@inject IUserNotificationService UserNotificationService
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject HttpClient Http
|
||||
@implements IDisposable
|
||||
@using System.Security.Claims
|
||||
|
||||
<MudThemeProvider Theme="_theme" @bind-IsDarkMode="_isDarkMode" />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
<MudLayout>
|
||||
<MudAppBar Elevation="0" Style="background: color-mix(in srgb, var(--mud-palette-appbar-background) 85%, transparent); border-bottom: 1px solid rgba(255, 255, 255, 0.08); color: var(--mud-palette-appbar-text);">
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" OnClick="ToggleDrawer" Class="d-flex d-md-none mr-2" />
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
<MudSpacer />
|
||||
<MudIconButton Icon="@(_isDarkMode ? Icons.Material.Filled.LightMode : Icons.Material.Filled.DarkMode)"
|
||||
Color="Color.Inherit"
|
||||
OnClick="ToggleDarkMode"
|
||||
Class="mr-2" />
|
||||
</MudAppBar>
|
||||
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<MudDrawer @bind-Open="_drawerOpen" ClipMode="DrawerClipMode.Never" Elevation="0" Variant="DrawerVariant.Mini" OpenMiniOnHover="false" Class="modern-drawer">
|
||||
<NavMenu OnToggleDrawer="ToggleDrawer" />
|
||||
</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;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
/* Custom scrollbar for the modern drawer */
|
||||
::deep .modern-drawer .mud-drawer-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
::deep .modern-drawer .mud-drawer-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::deep .modern-drawer .mud-drawer-content::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::deep .modern-drawer .mud-drawer-content::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
::deep .modern-drawer {
|
||||
background-color: var(--mud-palette-drawer-background) !important;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.1) !important;
|
||||
transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||
}
|
||||
|
||||
/* Glassmorphism for the appbar */
|
||||
::deep .mud-appbar {
|
||||
background: color-mix(in srgb, var(--mud-palette-appbar-background) 85%, transparent) !important;
|
||||
backdrop-filter: blur(12px) !important;
|
||||
-webkit-backdrop-filter: blur(12px) !important;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||
}
|
||||
|
||||
/* Smooth transition in main layout content */
|
||||
::deep .mud-main-content {
|
||||
transition: padding-left 0.25s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||
background-color: #F8FAFC;
|
||||
}
|
||||
|
||||
/* Blazor Error UI */
|
||||
#blazor-error-ui {
|
||||
color-scheme: light only;
|
||||
background: lightyellow;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
/* Base container covering full height */
|
||||
.nav-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
color: #94A3B8; /* Slate 400 */
|
||||
}
|
||||
|
||||
/* Ensure tooltips wrapper spans full width of menu */
|
||||
::deep .mud-tooltip-root {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Header with logo and title */
|
||||
.nav-header {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
margin-bottom: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.logo-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
::deep .drawer-toggle-btn {
|
||||
color: #94A3B8 !important;
|
||||
padding: 8px !important;
|
||||
margin-left: -8px !important;
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
|
||||
::deep .drawer-toggle-btn:hover {
|
||||
color: #FFFFFF !important;
|
||||
background-color: rgba(255, 255, 255, 0.05) !important;
|
||||
}
|
||||
|
||||
::deep .logo-icon {
|
||||
color: var(--mud-palette-secondary) !important;
|
||||
font-size: 1.6rem !important;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
color: var(--mud-palette-appbar-text);
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
/* Navigation scrollable content */
|
||||
.nav-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Divider inside navigation list */
|
||||
.admin-divider-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 20px 8px 20px;
|
||||
box-sizing: border-box;
|
||||
transition: padding 0.25s ease;
|
||||
}
|
||||
|
||||
.admin-divider-text {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: #475569; /* Slate 600 */
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-divider-line {
|
||||
flex: 1;
|
||||
border: none;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Standard NavLink customization */
|
||||
::deep .custom-nav-link {
|
||||
margin: 4px 12px !important;
|
||||
border-radius: 12px !important;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Normal (inactive) text and icon colors */
|
||||
::deep .custom-nav-link,
|
||||
::deep .custom-nav-link .mud-nav-link-text,
|
||||
::deep .custom-nav-link .mud-nav-link-icon-default {
|
||||
color: var(--mud-palette-drawer-text) !important;
|
||||
}
|
||||
|
||||
/* NavLink hover */
|
||||
::deep .custom-nav-link:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05) !important;
|
||||
}
|
||||
|
||||
::deep .custom-nav-link:hover,
|
||||
::deep .custom-nav-link:hover .mud-nav-link-text,
|
||||
::deep .custom-nav-link:hover .mud-nav-link-icon-default {
|
||||
color: var(--mud-palette-appbar-text) !important;
|
||||
}
|
||||
|
||||
/* NavLink active state (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,139 +0,0 @@
|
||||
@page "/agb"
|
||||
@rendermode InteractiveWebAssembly
|
||||
@attribute [AllowAnonymous]
|
||||
|
||||
<PageTitle>AGB – Timetracker</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Medium" Class="mt-8 pb-8">
|
||||
<MudStack Spacing="4">
|
||||
|
||||
@* ── Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Gavel" Style="color: var(--mud-palette-primary-text); font-size: 2.2rem;" />
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight: 700;">Allgemeine Geschäftsbedingungen (AGB)</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72;">
|
||||
Rechtlich absolut unverbindliches Kauderwelsch und Flachwitze
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@* ── Paragraphs with Flachwitze ── *@
|
||||
<MudCard Elevation="2" Class="rounded-xl">
|
||||
<MudCardContent>
|
||||
<MudText Typo="Typo.h6" Style="font-weight: 700; color: var(--mud-palette-primary);" Class="mb-2">
|
||||
§ 1 Geltungsbereich & Transparenz
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mb-3">
|
||||
Diese Bedingungen gelten für die gesamte Nutzung dieses Zeiterfassungs-Tools. Transparenz ist uns extrem wichtig.
|
||||
</MudText>
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="rounded-lg">
|
||||
<b>Warum können Geister eigentlich so schlecht lügen?</b><br />
|
||||
Weil sie einfach extrem leicht zu durchschauen sind!
|
||||
</MudAlert>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
<MudCard Elevation="2" Class="rounded-xl">
|
||||
<MudCardContent>
|
||||
<MudText Typo="Typo.h6" Style="font-weight: 700; color: var(--mud-palette-primary);" Class="mb-2">
|
||||
§ 2 Pflichten des Nutzers & Gärtnerklauseln
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mb-3">
|
||||
Jeder Nutzer verpflichtet sich, seine Arbeitszeiten wahrheitsgemäß und pünktlich einzutragen. Bei Zuwiderhandlung behalten wir uns vor, den Garten des Nutzers aufzuräumen.
|
||||
</MudText>
|
||||
<MudAlert Severity="Severity.Normal" Dense="true" Class="rounded-lg" Style="border-left: 4px solid #EF6C00; background: rgba(239, 108, 0, 0.05);">
|
||||
<b>Was sagt ein Gärtner, wenn er seinen Arbeitsplatz vorzeitig verlässt?</b><br />
|
||||
"Ich mach mich dann mal vom Acker!"
|
||||
</MudAlert>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
<MudCard Elevation="2" Class="rounded-xl">
|
||||
<MudCardContent>
|
||||
<MudText Typo="Typo.h6" Style="font-weight: 700; color: var(--mud-palette-primary);" Class="mb-2">
|
||||
§ 3 Haftungsausschluss & Straßenverkehr
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mb-3">
|
||||
Wir übernehmen keinerlei Haftung für vergessene Pausenzeiten, verpasste Feierabende oder schlechtes Wetter.
|
||||
</MudText>
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="rounded-lg">
|
||||
<b>Was ist rot, rund und steht am Straßenrand?</b><br />
|
||||
Eine Spargelampel!<br /><br />
|
||||
<b>Und warum dürfen Bienen eigentlich nicht in die Kirche?</b><br />
|
||||
Weil sie in der Sekte sind!
|
||||
</MudAlert>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
<MudCard Elevation="2" Class="rounded-xl">
|
||||
<MudCardContent>
|
||||
<MudText Typo="Typo.h6" Style="font-weight: 700; color: var(--mud-palette-primary);" Class="mb-2">
|
||||
§ 4 Datensicherheit & Physikalische Grundgesetze
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mb-3">
|
||||
Ihre Daten werden nach modernsten Verschlüsselungsstandards (nicht) an Dritte weitergegeben. Unsere Magnetbänder sind streng gesichert.
|
||||
</MudText>
|
||||
<MudAlert Severity="Severity.Normal" Dense="true" Class="rounded-lg" Style="border-left: 4px solid #00897B; background: rgba(0, 137, 123, 0.05);">
|
||||
<b>Treffen sich zwei Magnete im Rechenzentrum. Sagt der eine ganz verzweifelt:</b><br />
|
||||
"Du, sag mal... Was soll ich heute bloß anziehen?"
|
||||
</MudAlert>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
<MudCard Elevation="2" Class="rounded-xl">
|
||||
<MudCardContent>
|
||||
<MudText Typo="Typo.h6" Style="font-weight: 700; color: var(--mud-palette-primary);" Class="mb-2">
|
||||
§ 5 Rückgaberecht & Büroalltag
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mb-3">
|
||||
Erfasste Arbeitszeiten können nicht zurückgenommen, umgetauscht oder bar ausgezahlt werden.
|
||||
</MudText>
|
||||
<MudAlert Severity="Severity.Normal" Dense="true" Class="rounded-lg" Style="border-left: 4px solid #5E35B1; background: rgba(94, 53, 177, 0.05);">
|
||||
<b>Was macht ein gelangweilter Clown im Büro?</b><br />
|
||||
Er macht Faxen!<br /><br />
|
||||
<b>Und warum steht ein einsamer Pilz mitten im tiefen Wald?</b><br />
|
||||
Weil die Tannen zapfen!
|
||||
</MudAlert>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
<MudCard Elevation="2" Class="rounded-xl">
|
||||
<MudCardContent>
|
||||
<MudText Typo="Typo.h6" Style="font-weight: 700; color: var(--mud-palette-primary);" Class="mb-2">
|
||||
§ 6 Schlussbestimmungen & Salatgesetze
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mb-3">
|
||||
Sollten einzelne Bestimmungen dieser AGB unwirksam sein, bleibt die Fröhlichkeit aller anderen Bestimmungen unberührt.
|
||||
</MudText>
|
||||
<MudAlert Severity="Severity.Success" Dense="true" Class="rounded-lg">
|
||||
<b>Wie nennt man ein plötzlich spurlos verschwundenes Stück Rindfleisch?</b><br />
|
||||
Gulasch!<br /><br />
|
||||
<b>Was ist grün, gesund und klopft nachts an die Haustür?</b><br />
|
||||
Ein Klopfsalat!<br /><br />
|
||||
<b>Und zu guter Letzt: Warum fliegen Vögel im Winter eigentlich nach Süden?</b><br />
|
||||
Weil es zu Fuß einfach viel zu weit wäre!
|
||||
</MudAlert>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
@* ── Back Button ── *@
|
||||
<MudStack Row="true" Justify="Justify.Center" Class="mt-4">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.ArrowBack" OnClick="GoBack" Style="border-radius: 20px;">
|
||||
Zurück zur Anmeldung
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
[Inject] private NavigationManager Nav { get; set; } = default!;
|
||||
|
||||
private void GoBack()
|
||||
{
|
||||
Nav.NavigateTo("/login");
|
||||
}
|
||||
}
|
||||
@@ -1,614 +0,0 @@
|
||||
@page "/login"
|
||||
@rendermode InteractiveWebAssembly
|
||||
@attribute [AllowAnonymous]
|
||||
@using System.Net.Http.Json
|
||||
@inject IAuthService AuthService
|
||||
@inject NavigationManager Nav
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject HttpClient Http
|
||||
|
||||
<PageTitle>@GetPageTitle()</PageTitle>
|
||||
|
||||
@if (_tenantInfo == null)
|
||||
{
|
||||
@* Loading indicator while resolving Tenant *@
|
||||
<MudContainer MaxWidth="MaxWidth.Small" Class="mt-16 text-center">
|
||||
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
|
||||
<MudText Class="mt-4" Color="Color.Secondary">Lade Timetracker…</MudText>
|
||||
</MudContainer>
|
||||
}
|
||||
else if (_tenantInfo.IsTenant && !_tenantInfo.IsApproved)
|
||||
{
|
||||
@* Pending Approval Screen *@
|
||||
<MudContainer MaxWidth="MaxWidth.Small" Class="mt-16">
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="4">
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AccessTime" Style="font-size:4rem; color:#EAB308" />
|
||||
<MudText Typo="Typo.h4" Style="font-weight:700; color:#EAB308">Timetracker</MudText>
|
||||
</MudStack>
|
||||
|
||||
<MudPaper Elevation="4" Class="pa-8 rounded-xl text-center" Style="width:100%; border-top: 4px solid var(--mud-palette-warning);">
|
||||
<MudIcon Icon="@Icons.Material.Filled.HourglassEmpty"
|
||||
Style="font-size: 5rem; animation: pulse 2s infinite;"
|
||||
Color="Color.Warning"
|
||||
Class="mb-4" />
|
||||
<MudText Typo="Typo.h5" Style="font-weight: 700;" Class="mb-2">Freischaltung ausstehend</MudText>
|
||||
<MudText Typo="Typo.body1" Color="Color.Secondary" Class="mb-6">
|
||||
Die Firma <strong>@_tenantInfo.Name</strong> wurde erfolgreich registriert.
|
||||
Der Zugang wird derzeit von unserem SuperAdmin geprüft und in Kürze freigeschaltet.
|
||||
</MudText>
|
||||
<MudDivider Class="my-4" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Bitte laden Sie diese Seite in Kürze neu, sobald Ihr Account freigegeben wurde.
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
|
||||
<style>
|
||||
@@keyframes pulse {
|
||||
0% { opacity: 0.6; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.05); }
|
||||
100% { opacity: 0.6; transform: scale(1); }
|
||||
}
|
||||
</style>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Main login/registration container *@
|
||||
<MudContainer MaxWidth="MaxWidth.Small" Class="mt-16">
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="4">
|
||||
|
||||
@* ── Logo / Header ── *@
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AccessTime"
|
||||
Style="font-size:4rem; color:#0EA5E9" />
|
||||
<MudText Typo="Typo.h4" Style="font-weight:700; color:#0EA5E9">
|
||||
@(_tenantInfo.IsTenant ? _tenantInfo.Name : "Timetracker SaaS")
|
||||
</MudText>
|
||||
@if (!_tenantInfo.IsTenant)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Zentrales Portal</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
|
||||
<MudPaper Elevation="4" Class="pa-6 rounded-xl" Style="width:100%">
|
||||
@if (_firmRegisteredSuccessfully)
|
||||
{
|
||||
@* Success feedback after firm registration *@
|
||||
<MudStack Spacing="4" AlignItems="AlignItems.Center" Class="text-center pa-4">
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Style="font-size:5rem;" />
|
||||
<MudText Typo="Typo.h5" Style="font-weight:700;">Registrierung erfolgreich!</MudText>
|
||||
<MudText Typo="Typo.body1" Color="Color.Secondary">
|
||||
Ihre Firma <strong>@_firmModel.CompanyName</strong> wurde erfolgreich registriert.
|
||||
</MudText>
|
||||
<MudAlert Severity="Severity.Info" Class="rounded-lg">
|
||||
Ihr Zugang muss nun durch den SuperAdmin freigegeben werden. Danach können Sie sich unter Ihrer Subdomain anmelden:
|
||||
<br /><br />
|
||||
<strong>@GetTenantUrl()</strong>
|
||||
</MudAlert>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="mt-4" OnClick="ResetRegistrationFlow">
|
||||
Zurück zum Login
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
} else {
|
||||
@* ── Header / Navigation ── *@
|
||||
@if (_activeTab == 2)
|
||||
{
|
||||
@* ── Back Button for Firm Registration ── *@
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Class="mb-4">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" OnClick="@(() => SetTab(0))" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.h6" Style="font-weight:700">Firma registrieren</MudText>
|
||||
</MudStack>
|
||||
<MudDivider Class="mb-6" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ── Tab Navigation ── *@
|
||||
<MudStack Row="true" Justify="Justify.Center" Class="mb-4">
|
||||
<MudButton OnClick="@(() => SetTab(0))"
|
||||
Variant="@(_activeTab == 0 ? Variant.Filled : Variant.Text)"
|
||||
Color="Color.Primary"
|
||||
Style="min-width: 120px; border-radius: 20px;">
|
||||
Anmelden
|
||||
</MudButton>
|
||||
<MudButton OnClick="@(() => SetTab(1))"
|
||||
Variant="@(_activeTab == 1 ? Variant.Filled : Variant.Text)"
|
||||
Color="Color.Primary"
|
||||
Style="min-width: 120px; border-radius: 20px;">
|
||||
Registrieren
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudDivider Class="mb-6" />
|
||||
}
|
||||
|
||||
@if (_activeTab == 0)
|
||||
{
|
||||
@* ── Login Form ── *@
|
||||
<MudStack Spacing="3">
|
||||
@if (_error != null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
|
||||
}
|
||||
<EditForm Model="@_loginModel" OnValidSubmit="HandleLogin">
|
||||
<MudStack Spacing="3">
|
||||
<MudTextField T="string"
|
||||
Label="Benutzername"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Person"
|
||||
@bind-Value="_loginModel.Username"
|
||||
Required="true"
|
||||
AutoFocus="true" />
|
||||
<MudTextField T="string"
|
||||
Label="Passwort"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Lock"
|
||||
InputType="InputType.Password"
|
||||
@bind-Value="_loginModel.Password"
|
||||
Required="true" />
|
||||
<MudButton ButtonType="ButtonType.Submit"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
FullWidth="true"
|
||||
Size="Size.Large"
|
||||
StartIcon="@Icons.Material.Filled.Login"
|
||||
Class="mt-2"
|
||||
Disabled="_loading">
|
||||
@if (_loading)
|
||||
{
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
|
||||
}
|
||||
Anmelden
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</EditForm>
|
||||
</MudStack>
|
||||
}
|
||||
else if (_activeTab == 1)
|
||||
{
|
||||
@if (_tenantInfo.IsTenant)
|
||||
{
|
||||
@* ── Employee Register Form ── *@
|
||||
<MudStack Spacing="3">
|
||||
@if (_error != null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
|
||||
}
|
||||
<EditForm Model="@_registerModel" OnValidSubmit="HandleRegister">
|
||||
<MudStack Spacing="3">
|
||||
<MudTextField T="string"
|
||||
Label="Benutzername"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Person"
|
||||
@bind-Value="_registerModel.Username"
|
||||
Required="true"
|
||||
HelperText="Mindestens 3 Zeichen" />
|
||||
<MudTextField T="string"
|
||||
Label="Passwort"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Lock"
|
||||
InputType="InputType.Password"
|
||||
@bind-Value="_registerModel.Password"
|
||||
Required="true"
|
||||
HelperText="Mindestens 6 Zeichen" />
|
||||
<input type="text" style="display: none;" tabindex="-1" autocomplete="off" @bind="_honeypot" />
|
||||
|
||||
<MudCheckBox @bind-Value="_acceptAgb" Color="Color.Secondary" Class="mt-1">
|
||||
<MudText Typo="Typo.body2">Ich akzeptiere die <a href="/agb" target="_blank" style="color: var(--mud-palette-secondary); text-decoration: underline; font-weight: 600;">AGB</a>.</MudText>
|
||||
</MudCheckBox>
|
||||
|
||||
<MudButton ButtonType="ButtonType.Submit"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Secondary"
|
||||
FullWidth="true"
|
||||
Size="Size.Large"
|
||||
StartIcon="@Icons.Material.Filled.PersonAdd"
|
||||
Class="mt-2"
|
||||
Disabled="_loading || !_acceptAgb">
|
||||
@if (_loading)
|
||||
{
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
|
||||
}
|
||||
Konto erstellen
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</EditForm>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ── Single User Register Form ── *@
|
||||
<MudStack Spacing="3">
|
||||
@if (_error != null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
|
||||
}
|
||||
<EditForm Model="@_registerModel" OnValidSubmit="HandleSingleUserRegister">
|
||||
<MudStack Spacing="3">
|
||||
<MudTextField T="string"
|
||||
Label="Benutzername"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Person"
|
||||
@bind-Value="_registerModel.Username"
|
||||
Required="true"
|
||||
HelperText="Mindestens 3 Zeichen" />
|
||||
<MudTextField T="string"
|
||||
Label="Passwort"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Lock"
|
||||
InputType="InputType.Password"
|
||||
@bind-Value="_registerModel.Password"
|
||||
Required="true"
|
||||
HelperText="Mindestens 6 Zeichen" />
|
||||
<MudTextField T="string"
|
||||
Label="Passwort wiederholen"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Lock"
|
||||
InputType="InputType.Password"
|
||||
@bind-Value="_confirmPassword"
|
||||
Required="true" />
|
||||
<input type="text" style="display: none;" tabindex="-1" autocomplete="off" @bind="_honeypot" />
|
||||
|
||||
<MudCheckBox @bind-Value="_acceptAgb" Color="Color.Secondary" Class="mt-1">
|
||||
<MudText Typo="Typo.body2">Ich akzeptiere die <a href="/agb" target="_blank" style="color: var(--mud-palette-secondary); text-decoration: underline; font-weight: 600;">AGB</a>.</MudText>
|
||||
</MudCheckBox>
|
||||
|
||||
<MudButton ButtonType="ButtonType.Submit"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Secondary"
|
||||
FullWidth="true"
|
||||
Size="Size.Large"
|
||||
StartIcon="@Icons.Material.Filled.PersonAdd"
|
||||
Class="mt-2"
|
||||
Disabled="_loading || !_acceptAgb">
|
||||
@if (_loading)
|
||||
{
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
|
||||
}
|
||||
Konto erstellen
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</EditForm>
|
||||
</MudStack>
|
||||
}
|
||||
}
|
||||
else if (_activeTab == 2)
|
||||
{
|
||||
@* ── Firm Register Form ── *@
|
||||
<MudStack Spacing="3">
|
||||
@if (_error != null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
|
||||
}
|
||||
<EditForm Model="@_firmModel" OnValidSubmit="HandleRegisterFirm">
|
||||
<DataAnnotationsValidator />
|
||||
<MudStack Spacing="3">
|
||||
<MudTextField T="string"
|
||||
Label="Firmenname (z.B. Acme GmbH)"
|
||||
Variant="Variant.Outlined"
|
||||
@bind-Value="_firmModel.CompanyName"
|
||||
Required="true" />
|
||||
|
||||
<MudTextField T="string"
|
||||
Label="Subdomain"
|
||||
Variant="Variant.Outlined"
|
||||
HelperText="Nur Kleinbuchstaben, Zahlen & Bindestriche (z.B. acme)"
|
||||
@bind-Value="_firmModel.Subdomain"
|
||||
Required="true"
|
||||
Adornment="Adornment.End"
|
||||
AdornmentText="@GetDomainSuffix()" />
|
||||
|
||||
<MudDivider Class="my-2" />
|
||||
<MudText Typo="Typo.subtitle2" Style="font-weight:700;">Administrator-Konto</MudText>
|
||||
|
||||
<MudTextField T="string"
|
||||
Label="Benutzername"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Person"
|
||||
@bind-Value="_firmModel.AdminUsername"
|
||||
Required="true"
|
||||
HelperText="Mindestens 3 Zeichen" />
|
||||
|
||||
<MudTextField T="string"
|
||||
Label="Passwort"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Lock"
|
||||
InputType="InputType.Password"
|
||||
@bind-Value="_firmModel.AdminPassword"
|
||||
Required="true"
|
||||
HelperText="Mindestens 6 Zeichen" />
|
||||
|
||||
<MudTextField T="string"
|
||||
Label="Passwort wiederholen"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Lock"
|
||||
InputType="InputType.Password"
|
||||
@bind-Value="_firmModel.ConfirmPassword"
|
||||
Required="true" />
|
||||
|
||||
<MudCheckBox @bind-Value="_acceptAgb" Color="Color.Secondary" Class="mt-1">
|
||||
<MudText Typo="Typo.body2">Ich akzeptiere die <a href="/agb" target="_blank" style="color: var(--mud-palette-secondary); text-decoration: underline; font-weight: 600;">AGB</a>.</MudText>
|
||||
</MudCheckBox>
|
||||
|
||||
<MudButton ButtonType="ButtonType.Submit"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Secondary"
|
||||
FullWidth="true"
|
||||
Size="Size.Large"
|
||||
StartIcon="@Icons.Material.Filled.Business"
|
||||
Class="mt-2"
|
||||
Disabled="_loading || !_acceptAgb">
|
||||
@if (_loading)
|
||||
{
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
|
||||
}
|
||||
Firma registrieren
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</EditForm>
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@if (!_tenantInfo.IsTenant && _activeTab != 2)
|
||||
{
|
||||
<MudDivider Class="my-4" />
|
||||
<MudStack AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
Sie möchten Timetracker für Ihr Unternehmen nutzen?
|
||||
</MudText>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Primary" OnClick="@(() => SetTab(2))" StartIcon="@Icons.Material.Filled.Business">
|
||||
Eigene Firma registrieren
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
}
|
||||
}
|
||||
</MudPaper>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
}
|
||||
|
||||
@code {
|
||||
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,179 +0,0 @@
|
||||
@inject IJSRuntime JS
|
||||
|
||||
@if (_active)
|
||||
{
|
||||
@* Dark Backdrop Overlay *@
|
||||
<div class="onboarding-backdrop"></div>
|
||||
|
||||
@* Onboarding Instruction Card *@
|
||||
<div class="onboarding-card-container">
|
||||
<MudCard Class="onboarding-info-card pa-5 rounded-xl shadow-2xl">
|
||||
<MudCardContent Class="pa-0">
|
||||
<MudStack Spacing="3">
|
||||
@* Header / Title *@
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AutoAwesome" Style="color: #0EA5E9;" />
|
||||
<MudText Typo="Typo.h6" Style="font-weight: 700; color: white;">
|
||||
@_steps[_currentStep].Title
|
||||
</MudText>
|
||||
</MudStack>
|
||||
|
||||
@* Explanation Text *@
|
||||
<MudText Typo="Typo.body2" Style="color: #CBD5E1; line-height: 1.6; min-height: 60px;">
|
||||
@_steps[_currentStep].Content
|
||||
</MudText>
|
||||
|
||||
@* Footer / Progress & Buttons *@
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mt-2">
|
||||
@* Step Count *@
|
||||
<MudText Typo="Typo.caption" Style="color: #94A3B8; font-weight: 600;">
|
||||
Schritt @(_currentStep + 1) von @_steps.Count
|
||||
</MudText>
|
||||
|
||||
@* Navigation Button Stack *@
|
||||
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
|
||||
@if (_currentStep > 0)
|
||||
{
|
||||
<MudButton Variant="Variant.Text"
|
||||
Style="color: #94A3B8; text-transform: none; border-radius: 12px;"
|
||||
OnClick="PrevStep"
|
||||
Size="Size.Small">
|
||||
Zurück
|
||||
</MudButton>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudButton Variant="Variant.Text"
|
||||
Style="color: #94A3B8; text-transform: none; border-radius: 12px;"
|
||||
OnClick="CompleteTour"
|
||||
Size="Size.Small">
|
||||
Überspringen
|
||||
</MudButton>
|
||||
}
|
||||
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Secondary"
|
||||
Style="text-transform: none; border-radius: 12px; font-weight: 700; min-width: 80px;"
|
||||
OnClick="NextStep"
|
||||
Size="Size.Small">
|
||||
@(_currentStep == _steps.Count - 1 ? "Fertig" : "Weiter")
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</div>
|
||||
}
|
||||
|
||||
<style>
|
||||
/* Backdrop behind cards but below the spotlight highlight */
|
||||
.onboarding-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
backdrop-filter: blur(2px);
|
||||
z-index: 9999;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
/* Fixed positioned instruction box at the bottom center */
|
||||
.onboarding-card-container {
|
||||
position: fixed;
|
||||
bottom: 32px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 90%;
|
||||
max-width: 480px;
|
||||
z-index: 10001;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Dark theme styling for the instruction card */
|
||||
.onboarding-info-card {
|
||||
background: rgba(15, 23, 42, 0.9) !important;
|
||||
backdrop-filter: blur(16px) !important;
|
||||
-webkit-backdrop-filter: blur(16px) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.4) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
[Parameter] public EventCallback OnFinished { get; set; }
|
||||
|
||||
private bool _active = true;
|
||||
private int _currentStep = 0;
|
||||
|
||||
private readonly List<OnboardingStep> _steps =
|
||||
[
|
||||
new(".onboarding-nav-menu", "Die Navigation", "Hier auf der linken Seite findest du das Hauptmenü. Du kannst ganz einfach zwischen der Wochen- und Monatsübersicht wechseln oder deine Statistiken einsehen."),
|
||||
new(".onboarding-week-header", "Kalenderwoche blättern", "Hier siehst du die aktuell ausgewählte Woche. Verwende die Pfeiltasten links und rechts, um in vergangenen oder zukünftigen Wochen deine Zeiten zu erfassen."),
|
||||
new(".onboarding-day-card", "Zeiterfassung pro Tag", "Das Herzstück des Timetrackers. Trage hier deine täglichen Start- und Endzeiten sowie deine Pausen ein. Das System errechnet die Zeiten und Überstunden in Echtzeit."),
|
||||
new(".onboarding-week-summary", "Wochenzusammenfassung", "Hier siehst du auf einen Blick, wie viele Stunden du in dieser Woche gearbeitet hast, wie viel Pause du gemacht hast und wie sich dein Stundensaldo verändert hat."),
|
||||
new(".onboarding-overtime-balance", "Gleitzeitkonto", "Hier wird dein gesamtes Gleitzeitkonto über das Jahr hinweg zusammengerechnet. So weißt du immer genau, wie viele Überstunden du angesammelt hast.")
|
||||
];
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
await HighlightStep();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HighlightStep()
|
||||
{
|
||||
if (_active && _steps.Count > 0)
|
||||
{
|
||||
var step = _steps[_currentStep];
|
||||
await JS.InvokeVoidAsync("window.onboarding.highlight", step.TargetSelector);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task NextStep()
|
||||
{
|
||||
if (_currentStep < _steps.Count - 1)
|
||||
{
|
||||
_currentStep++;
|
||||
await HighlightStep();
|
||||
}
|
||||
else
|
||||
{
|
||||
await CompleteTour();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PrevStep()
|
||||
{
|
||||
if (_currentStep > 0)
|
||||
{
|
||||
_currentStep--;
|
||||
await HighlightStep();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CompleteTour()
|
||||
{
|
||||
_active = false;
|
||||
await JS.InvokeVoidAsync("window.onboarding.clear");
|
||||
await OnFinished.InvokeAsync();
|
||||
}
|
||||
|
||||
private class OnboardingStep
|
||||
{
|
||||
public string TargetSelector { get; }
|
||||
public string Title { get; }
|
||||
public string Content { get; }
|
||||
|
||||
public OnboardingStep(string targetSelector, string title, string content)
|
||||
{
|
||||
TargetSelector = targetSelector;
|
||||
Title = title;
|
||||
Content = content;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
@page "/profile"
|
||||
@rendermode InteractiveWebAssembly
|
||||
@attribute [Authorize]
|
||||
@using System.Security.Claims
|
||||
@inject IAuthService AuthService
|
||||
@inject ITimetrackerService TrackerService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<PageTitle>Benutzerprofil – Timetracker</PageTitle>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<MudStack AlignItems="AlignItems.Center" Class="mt-16">
|
||||
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
|
||||
<MudText Color="Color.Secondary">Lade Profil…</MudText>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Spacing="4">
|
||||
|
||||
@* ── Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AccountCircle" Style="color: var(--mud-palette-primary-text); font-size:2.5rem" />
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700">Mein Profil</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72;">
|
||||
Persönliche Details verwalten und Account sichern
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
<MudGrid Spacing="4">
|
||||
|
||||
@* ── Übersicht & Details ── *@
|
||||
<MudItem xs="12" md="4">
|
||||
<MudCard Elevation="3" Class="rounded-xl h-100">
|
||||
<MudCardContent>
|
||||
<MudStack AlignItems="AlignItems.Center" Class="py-6" Spacing="3">
|
||||
<MudAvatar Style="width:96px; height:96px; font-size:2rem; font-weight:700; background: linear-gradient(135deg, #0EA5E9 0%, #0284C7 100%); color: white;">
|
||||
@GetInitials(_username)
|
||||
</MudAvatar>
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="0">
|
||||
<MudText Typo="Typo.h6" Style="font-weight:700">@_username</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">@(_isAdmin ? "Administrator" : "Standardbenutzer")</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
|
||||
<MudDivider Class="my-3" />
|
||||
|
||||
<MudStack Spacing="2" Class="pa-2">
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Benutzer-ID</MudText>
|
||||
<MudText Typo="Typo.body2"><b>#@_userId</b></MudText>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Gleitzeit-Saldo</MudText>
|
||||
<MudText Typo="Typo.body2" Color="@(_overtime >= TimeSpan.Zero ? Color.Success : Color.Warning)">
|
||||
<b>@FormatTs(_overtime, sign: true)</b>
|
||||
</MudText>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Sollstunden / Tag</MudText>
|
||||
<MudText Typo="Typo.body2"><b>@_settings.DailyTargetHours h</b></MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* ── Einstellungen verwalten ── *@
|
||||
<MudItem xs="12" md="8">
|
||||
<MudStack Spacing="4">
|
||||
|
||||
@* Benutzername ändern *@
|
||||
<MudCard Elevation="3" Class="rounded-xl">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Badge" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.h6" Style="font-weight:600">Benutzername ändern</MudText>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudForm @ref="_renameForm" @bind-IsValid="_renameIsValid">
|
||||
<MudStack Spacing="3">
|
||||
<MudTextField @bind-Value="_newUsername"
|
||||
Label="Neuer Benutzername"
|
||||
Variant="Variant.Outlined"
|
||||
Required="true"
|
||||
RequiredError="Bitte gib einen Benutzernamen ein."
|
||||
Validation="@(new Func<string, string?>(ValidateUsername))" />
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
OnClick="HandleRename"
|
||||
Disabled="!_renameIsValid || _newUsername == _username"
|
||||
Class="align-self-start rounded-lg py-2 px-4"
|
||||
StartIcon="@Icons.Material.Filled.Save">
|
||||
Speichern
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudForm>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
@* Passwort ändern *@
|
||||
<MudCard Elevation="3" Class="rounded-xl">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Lock" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.h6" Style="font-weight:600">Passwort ändern</MudText>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudForm @ref="_passwordForm" @bind-IsValid="_passwordIsValid">
|
||||
<MudStack Spacing="3">
|
||||
<MudTextField @bind-Value="_currentPassword"
|
||||
Label="Aktuelles Passwort"
|
||||
InputType="InputType.Password"
|
||||
Variant="Variant.Outlined"
|
||||
Required="true"
|
||||
RequiredError="Bitte gib dein aktuelles Passwort ein." />
|
||||
<MudTextField @bind-Value="_newPassword"
|
||||
Label="Neues Passwort"
|
||||
InputType="InputType.Password"
|
||||
Variant="Variant.Outlined"
|
||||
Required="true"
|
||||
RequiredError="Bitte gib dein neues Passwort ein."
|
||||
Validation="@(new Func<string, string?>(ValidatePassword))" />
|
||||
<MudTextField @bind-Value="_confirmPassword"
|
||||
Label="Neues Passwort bestätigen"
|
||||
InputType="InputType.Password"
|
||||
Variant="Variant.Outlined"
|
||||
Required="true"
|
||||
RequiredError="Bitte bestätige dein neues Passwort."
|
||||
Validation="@(new Func<string, string?>(ValidateConfirmPassword))" />
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
OnClick="HandleChangePassword"
|
||||
Disabled="!_passwordIsValid"
|
||||
Class="align-self-start rounded-lg py-2 px-4"
|
||||
StartIcon="@Icons.Material.Filled.VpnKey">
|
||||
Passwort ändern
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudForm>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
|
||||
</MudGrid>
|
||||
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@code {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
@namespace timetracker.Client.Components.Pages
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<MudText Class="mb-4">Geben Sie das neue Passwort für den Benutzer <strong>@Username</strong> ein.</MudText>
|
||||
<MudTextField @bind-Value="_newPassword"
|
||||
Label="Neues Passwort"
|
||||
InputType="InputType.Password"
|
||||
Variant="Variant.Outlined"
|
||||
AutoFocus="true"
|
||||
Required="true"
|
||||
HelperText="Mindestens 6 Zeichen" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel">Abbrechen</MudButton>
|
||||
<MudButton Color="Color.Warning" Variant="Variant.Filled" OnClick="Submit" Disabled="@(string.IsNullOrEmpty(_newPassword) || _newPassword.Length < 6)">Passwort zurücksetzen</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!;
|
||||
[Parameter] public string Username { get; set; } = "";
|
||||
|
||||
private string _newPassword = "";
|
||||
|
||||
private void Submit() => MudDialog.Close(DialogResult.Ok(_newPassword));
|
||||
private void Cancel() => MudDialog.Cancel();
|
||||
}
|
||||
@@ -1,481 +0,0 @@
|
||||
@page "/stats"
|
||||
@rendermode InteractiveWebAssembly
|
||||
@attribute [Authorize]
|
||||
@inject ITimetrackerService TrackerService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager NavigationManager
|
||||
@using System.Security.Claims
|
||||
@using timetracker.Shared
|
||||
|
||||
<PageTitle>Statistiken – Timetracker</PageTitle>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<MudStack AlignItems="AlignItems.Center" Class="mt-16" Spacing="3">
|
||||
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
|
||||
<MudText Color="Color.Secondary">Lade Statistiken…</MudText>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Spacing="4">
|
||||
|
||||
@* ── Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
|
||||
<MudIcon Icon="@Icons.Material.Filled.BarChart" Style="color: var(--mud-palette-primary-text); font-size:2.2rem" />
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700">Statistiken</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">Auswertung deiner Arbeitsleistung und Gleitzeitkonto</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
<MudGrid Spacing="4">
|
||||
@* ── Card 1: Wochen-Fortschritt ── *@
|
||||
<MudItem xs="12" md="4">
|
||||
<MudCard Elevation="3" Class="rounded-xl" Style="height:100%;">
|
||||
<MudCardContent>
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="3">
|
||||
<MudText Typo="Typo.subtitle1" Style="font-weight:700; color:#475569">Wochenfortschritt</MudText>
|
||||
|
||||
@{
|
||||
var pct = _weekTargetHours > 0 ? (int)Math.Min((_weekWorkedHours / _weekTargetHours) * 100, 100) : 0;
|
||||
var displayPct = _weekTargetHours > 0 ? (int)Math.Round((_weekWorkedHours / _weekTargetHours) * 100) : 0;
|
||||
}
|
||||
|
||||
<div style="position:relative; display:inline-flex;">
|
||||
<MudProgressCircular Value="@pct" Color="Color.Secondary" Size="Size.Large" StrokeWidth="6" Style="height:120px; width:120px;" />
|
||||
<div style="position:absolute; top:0; left:0; bottom:0; right:0; display:flex; align-items:center; justify-content:center; flex-direction:column;">
|
||||
<MudText Typo="Typo.h5" Style="font-weight:800; color: var(--mud-palette-text-primary);">@displayPct%</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Erreicht</MudText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MudStack Spacing="1" Style="width:100%;" Class="mt-2">
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Arbeitszeit (Ist):</MudText>
|
||||
<MudText Typo="Typo.body2" Style="font-weight:700; color: var(--mud-palette-text-primary)">@FormatHours(_weekWorkedHours) Std.</MudText>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Wochensoll (Soll):</MudText>
|
||||
<MudText Typo="Typo.body2" Style="font-weight:700">@FormatHours(_weekTargetHours) Std.</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* ── Card 2: Wochensaldo ── *@
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudCard Elevation="3" Class="rounded-xl" Style="height:100%; border-left: 6px solid var(--mud-palette-primary);">
|
||||
<MudCardContent>
|
||||
<MudStack Spacing="2">
|
||||
<MudText Typo="Typo.subtitle1" Style="font-weight:700; color:#475569">Wochensaldo</MudText>
|
||||
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Class="py-3">
|
||||
<MudIcon Icon="@(_weekOvertimeHours >= 0 ? Icons.Material.Filled.TrendingUp : Icons.Material.Filled.TrendingDown)"
|
||||
Style="@($"color:{(_weekOvertimeHours >= 0 ? "#10B981" : "#EF4444")}; font-size:3rem")" />
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.h3" Style="@($"font-weight:800; color:{(_weekOvertimeHours >= 0 ? "#10B981" : "#EF4444")};")">
|
||||
@FormatTs(TimeSpan.FromHours(_weekOvertimeHours), sign: true)
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Überstunden-Veränderung diese Woche</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* ── Card 3: Gesamtsaldo ── *@
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudCard Elevation="3" Class="rounded-xl" Style="@($"height:100%; border-left: 6px solid {(_totalOvertime >= TimeSpan.Zero ? "#10B981" : "#EF4444")};")">
|
||||
<MudCardContent>
|
||||
<MudStack Spacing="2">
|
||||
<MudText Typo="Typo.subtitle1" Style="font-weight:700; color:#475569">Gleitzeitkonto</MudText>
|
||||
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Class="py-3">
|
||||
<MudIcon Icon="Icons.Material.Filled.AccountBalance"
|
||||
Style="@($"color:{(_totalOvertime >= TimeSpan.Zero ? "#10B981" : "#EF4444")}; font-size:3rem")" />
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.h3" Style="@($"font-weight:800; color:{(_totalOvertime >= TimeSpan.Zero ? "#10B981" : "#EF4444")};")">
|
||||
@FormatTs(_totalOvertime, sign: true)
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Gesamtsaldo aller erfassten Tage</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* ── Säulendiagramm (Tagesverteilung) ── *@
|
||||
<MudItem xs="12">
|
||||
<MudCard Elevation="3" Class="rounded-xl">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6" Style="font-weight:700; color: var(--mud-palette-text-primary)">Arbeitszeitverteilung diese Woche</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Geleistete Nettoarbeitsstunden pro Wochentag</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<div class="chart-container" style="width:100%; overflow-x:auto;">
|
||||
<svg viewBox="0 0 640 320" width="100%" height="320" style="min-width: 500px; display: block; overflow: visible;">
|
||||
<style>
|
||||
.chart-bar {
|
||||
transition: height 0.4s ease, y 0.4s ease, fill 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chart-bar:hover {
|
||||
filter: brightness(1.1) drop-shadow(0px 4px 8px rgba(14, 165, 233, 0.3));
|
||||
}
|
||||
</style>
|
||||
|
||||
@{
|
||||
double plotLeft = 50;
|
||||
double plotRight = 580;
|
||||
double plotTop = 30;
|
||||
double plotBottom = 270;
|
||||
double plotWidth = plotRight - plotLeft;
|
||||
double plotHeight = plotBottom - plotTop;
|
||||
double barWidth = 36;
|
||||
double spacing = plotWidth / 7;
|
||||
}
|
||||
|
||||
<!-- Horizontal Grid Lines -->
|
||||
@for (int i = 0; i <= 4; i++)
|
||||
{
|
||||
double val = (_maxHoursValue / 4.0) * i;
|
||||
double yPos = plotBottom - (val / _maxHoursValue) * plotHeight;
|
||||
<line x1="@F(plotLeft)" y1="@F(yPos)" x2="@F(plotRight)" y2="@F(yPos)" stroke="#E2E8F0" stroke-width="1" stroke-dasharray="2" />
|
||||
@((MarkupString)$"<text x=\"{F(plotLeft - 8)}\" y=\"{F(yPos + 4)}\" fill=\"#64748B\" font-size=\"11\" font-weight=\"500\" text-anchor=\"end\">{val:F1}h</text>")
|
||||
}
|
||||
|
||||
<!-- Bars and Labels -->
|
||||
@for (int i = 0; i < 7; i++)
|
||||
{
|
||||
var d = _days[i];
|
||||
double xPos = plotLeft + (i * spacing) + (spacing - barWidth) / 2;
|
||||
double bHeight = (d.WorkedHours / _maxHoursValue) * plotHeight;
|
||||
double yPos = plotBottom - bHeight;
|
||||
|
||||
string fillCol = "#94A3B8"; // Default light slate
|
||||
if (d.IsToday)
|
||||
{
|
||||
fillCol = "var(--mud-palette-secondary)"; // Brand color for today
|
||||
}
|
||||
else if (d.WorkedHours >= d.TargetHours && d.TargetHours > 0)
|
||||
{
|
||||
fillCol = "#475569"; // Slate Blue for normal completed target
|
||||
}
|
||||
else if (d.WorkedHours > 0 && d.TargetHours == 0)
|
||||
{
|
||||
fillCol = "#10B981"; // Emerald Green for weekend work (pure overtime)
|
||||
}
|
||||
else if (d.WorkedHours > 0)
|
||||
{
|
||||
fillCol = "#64748B"; // Slate Medium for incomplete target
|
||||
}
|
||||
else
|
||||
{
|
||||
fillCol = "#E2E8F0"; // Very light grey for zero hours
|
||||
}
|
||||
|
||||
<!-- Render Rect with hover tooltip -->
|
||||
<rect x="@F(xPos)" y="@F(d.WorkedHours > 0 ? yPos : plotBottom - 4)" width="@F(barWidth)" height="@F(d.WorkedHours > 0 ? bHeight : 4)"
|
||||
rx="6" fill="@fillCol" class="chart-bar">
|
||||
<title>@d.DayName: @FormatHours(d.WorkedHours) Std. (Soll: @FormatHours(d.TargetHours) Std.)</title>
|
||||
</rect>
|
||||
|
||||
<!-- Value Label above bar -->
|
||||
@if (d.WorkedHours > 0)
|
||||
{
|
||||
@((MarkupString)$"<text x=\"{F(xPos + barWidth / 2)}\" y=\"{F(yPos - 6)}\" fill=\"var(--mud-palette-text-primary)\" font-size=\"11\" font-weight=\"700\" text-anchor=\"middle\">{FormatHours(d.WorkedHours)}</text>")
|
||||
}
|
||||
|
||||
<!-- Wochentag Text -->
|
||||
@((MarkupString)$"<text x=\"{F(xPos + barWidth / 2)}\" y=\"{F(plotBottom + 20)}\" fill=\"#64748B\" font-size=\"11\" font-weight=\"700\" text-anchor=\"middle\">{d.DayShortName}</text>")
|
||||
}
|
||||
|
||||
<!-- Sollzeit Target Line (Only if target hours > 0) -->
|
||||
@if (_settings.DailyTargetHours > 0)
|
||||
{
|
||||
double yTarget = plotBottom - (_settings.DailyTargetHours / _maxHoursValue) * plotHeight;
|
||||
<line x1="@F(plotLeft)" y1="@F(yTarget)" x2="@F(plotRight)" y2="@F(yTarget)" stroke="#EF4444" stroke-width="2" stroke-dasharray="4" style="opacity: 0.75;" />
|
||||
@((MarkupString)$"<text x=\"{F(plotRight - 10)}\" y=\"{F(yTarget - 6)}\" fill=\"#EF4444\" font-size=\"10\" font-weight=\"700\" text-anchor=\"end\">Soll ({_settings.DailyTargetHours} h)</text>")
|
||||
}
|
||||
</svg>
|
||||
</div>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* ── Excel Export ── *@
|
||||
<MudItem xs="12">
|
||||
<MudCard Elevation="3" Class="rounded-xl">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6" Style="font-weight:700; color: var(--mud-palette-text-primary)">Excel-Datenexport</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Lade deine erfassten Arbeitszeiten in verschiedenen Zeiträumen als formatierte Excel-Datei herunter.</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudGrid Spacing="3">
|
||||
@* Wochenexport *@
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudStack Spacing="3">
|
||||
<MudText Typo="Typo.subtitle2" Style="font-weight:700">Wochenexport</MudText>
|
||||
<MudDatePicker Label="Woche wählen" @bind-Date="_exportWeekDate" PickerVariant="PickerVariant.Dialog" Variant="Variant.Outlined" Color="Color.Primary" />
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Download" Variant="Variant.Filled" Color="Color.Primary" OnClick="ExportWeek" FullWidth="true">
|
||||
Woche exportieren
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
|
||||
@* Monatsexport *@
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudStack Spacing="3">
|
||||
<MudText Typo="Typo.subtitle2" Style="font-weight:700">Monatsexport</MudText>
|
||||
<MudStack Row="true" Spacing="2">
|
||||
<MudSelect T="int" Label="Monat" @bind-Value="_exportMonth" Variant="Variant.Outlined">
|
||||
@for (int m = 1; m <= 12; m++)
|
||||
{
|
||||
var monthVal = m;
|
||||
<MudSelectItem Value="@monthVal">@_deCulture.DateTimeFormat.GetMonthName(monthVal)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudSelect T="int" Label="Jahr" @bind-Value="_exportMonthYear" Variant="Variant.Outlined">
|
||||
@for (int y = DateTime.Today.Year - 5; y <= DateTime.Today.Year + 1; y++)
|
||||
{
|
||||
var yearVal = y;
|
||||
<MudSelectItem Value="@yearVal">@yearVal</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Download" Variant="Variant.Filled" Color="Color.Primary" OnClick="ExportMonth" FullWidth="true">
|
||||
Monat exportieren
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
|
||||
@* Jahresexport *@
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudStack Spacing="3">
|
||||
<MudText Typo="Typo.subtitle2" Style="font-weight:700">Jahresexport</MudText>
|
||||
<MudSelect T="int" Label="Jahr" @bind-Value="_exportYear" Variant="Variant.Outlined">
|
||||
@for (int y = DateTime.Today.Year - 5; y <= DateTime.Today.Year + 1; y++)
|
||||
{
|
||||
var yearVal = y;
|
||||
<MudSelectItem Value="@yearVal">@yearVal</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Download" Variant="Variant.Filled" Color="Color.Primary" OnClick="ExportYear" FullWidth="true">
|
||||
Jahr exportieren
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* ── Diagnostics Panel ── *@
|
||||
<MudItem xs="12">
|
||||
<MudExpansionPanels>
|
||||
<MudExpansionPanel Text="Diagnose-Daten (Debug)">
|
||||
<MudText Typo="Typo.body2">Benutzer-ID: @_userId</MudText>
|
||||
<MudText Typo="Typo.body2">Wochensoll: @_weekTargetHours Std. | Netto-Arbeitszeit: @_weekWorkedHours Std. | Wochensaldo: @_weekOvertimeHours Std.</MudText>
|
||||
<MudText Typo="Typo.body2">Anzahl erfasste Tage aus DB: @_rawDbDaysCount</MudText>
|
||||
|
||||
<MudSimpleTable Style="margin-top: 10px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Tag</th>
|
||||
<th>Ist (Std)</th>
|
||||
<th>Soll (Std)</th>
|
||||
<th>Ist > 0</th>
|
||||
<th>Arbeitstag (Einst.)</th>
|
||||
<th>Aus DB zugeordnet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var d in _days)
|
||||
{
|
||||
<tr>
|
||||
<td>@d.Date.ToString("yyyy-MM-dd")</td>
|
||||
<td>@d.DayName</td>
|
||||
<td>@d.WorkedHours</td>
|
||||
<td>@d.TargetHours</td>
|
||||
<td>@(d.WorkedHours > 0 ? "Ja" : "Nein")</td>
|
||||
<td>@(d.IsWorkDay ? "Ja" : "Nein")</td>
|
||||
<td>@(_dbMatchStatus.GetValueOrDefault(d.Date, "Nein"))</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudExpansionPanel>
|
||||
</MudExpansionPanels>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@code {
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
@page "/superadmin"
|
||||
@rendermode InteractiveWebAssembly
|
||||
@attribute [Authorize(Policy = "AdminOnly")]
|
||||
@using System.Net.Http.Json
|
||||
@inject HttpClient Http
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
|
||||
<PageTitle>Firmenverwaltung – Timetracker SaaS</PageTitle>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<MudStack AlignItems="AlignItems.Center" Class="mt-16" Spacing="3">
|
||||
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
|
||||
<MudText Color="Color.Secondary">Lade Firmen und Mandanten…</MudText>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Spacing="4">
|
||||
|
||||
@* ── Header Card ── *@
|
||||
<MudPaper Elevation="4" Class="pa-6 rounded-xl"
|
||||
Style="background: linear-gradient(135deg, var(--mud-palette-primary-darken) 0%, var(--mud-palette-primary) 100%); color: var(--mud-palette-primary-text);">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="4">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Business" Style="color: var(--mud-palette-secondary); font-size:3rem" />
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:800">SuperAdmin Dashboard</MudText>
|
||||
<MudText Typo="Typo.body2" Style="color: var(--mud-palette-primary-text); opacity: 0.72">
|
||||
@_tenants.Count registrierte Firmen im System
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@* ── Tenants Table ── *@
|
||||
<MudCard Elevation="3" Class="rounded-xl overflow-hidden">
|
||||
<MudCardContent Class="pa-0">
|
||||
<MudTable Items="_tenants" Hover="true" Striped="true" Elevation="0" SortLabel="Sortieren">
|
||||
<HeaderContent>
|
||||
<MudTh Style="width: 50px;" />
|
||||
<MudTh><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.Id)">ID</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.Name)">Name</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.Subdomain)">Subdomain</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.CreatedAt)">Erstellt am</MudTableSortLabel></MudTh>
|
||||
<MudTh Style="text-align:center"><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.UserCount)">Benutzer</MudTableSortLabel></MudTh>
|
||||
<MudTh Style="text-align:center"><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.WorkDaysCount)">Arbeitstage</MudTableSortLabel></MudTh>
|
||||
<MudTh Style="text-align:center">Status</MudTh>
|
||||
<MudTh Style="text-align:right">Aktionen</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>
|
||||
<MudIconButton Icon="@(context.ShowDetails ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)"
|
||||
Size="Size.Small"
|
||||
OnClick="@(() => context.ShowDetails = !context.ShowDetails)" />
|
||||
</MudTd>
|
||||
<MudTd DataLabel="ID">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@context.Id</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Name">
|
||||
<MudText Typo="Typo.body1" Style="font-weight:600">@context.Name</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Subdomain">
|
||||
<MudChip T="string" Color="Color.Primary" Variant="Variant.Outlined" Size="Size.Small">
|
||||
@context.Subdomain
|
||||
</MudChip>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Erstellt am">
|
||||
<MudText Typo="Typo.body2">@context.CreatedAt.ToLocalTime().ToString("dd.MM.yyyy HH:mm")</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Benutzer" Style="text-align:center">
|
||||
<MudBadge Content="@context.UserCount" Color="Color.Default" Overlap="false" Class="mx-2" />
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Arbeitstage" Style="text-align:center">
|
||||
<MudText Typo="Typo.body2">@context.WorkDaysCount</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Status" Style="text-align:center">
|
||||
@if (context.IsApproved)
|
||||
{
|
||||
<MudChip T="string" Color="Color.Success" Size="Size.Small" Icon="@Icons.Material.Filled.Check">Aktiv</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Color="Color.Warning" Size="Size.Small" Icon="@Icons.Material.Filled.HourglassEmpty">Ausstehend</MudChip>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd Style="text-align:right">
|
||||
@if (!context.IsApproved)
|
||||
{
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Success"
|
||||
Size="Size.Small"
|
||||
StartIcon="@Icons.Material.Filled.CheckCircle"
|
||||
OnClick="@(() => ApproveTenant(context))"
|
||||
Class="mr-1">
|
||||
Freigeben
|
||||
</MudButton>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Warning"
|
||||
Size="Size.Small"
|
||||
StartIcon="@Icons.Material.Filled.Block"
|
||||
OnClick="@(() => ToggleActiveTenant(context))"
|
||||
Class="mr-1">
|
||||
Sperren
|
||||
</MudButton>
|
||||
}
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Color="Color.Error"
|
||||
Size="Size.Small"
|
||||
OnClick="@(() => DeleteTenant(context))" />
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<ChildRowContent>
|
||||
@if (context.ShowDetails)
|
||||
{
|
||||
<MudTr>
|
||||
<td colspan="9" style="background-color: var(--mud-palette-background-grey); padding: 16px;">
|
||||
<MudPaper Elevation="1" Class="pa-4 rounded-lg">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-3" Style="font-weight: 700;">
|
||||
Benutzer für Firma: @context.Name
|
||||
</MudText>
|
||||
@if (context.Users == null || context.Users.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Keine Benutzer in dieser Firma registriert.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid Spacing="2">
|
||||
@foreach (var user in context.Users)
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudPaper Outlined="true" Class="pa-3 rounded-lg d-flex align-center justify-space-between" Style="background: white;">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudAvatar Color="user.IsTenantAdmin ? Color.Primary : Color.Default" Size="Size.Small">
|
||||
@user.Username.Substring(0, Math.Min(2, user.Username.Length)).ToUpper()
|
||||
</MudAvatar>
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.body2" Style="font-weight: 600;">@user.Username</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">ID: @user.Id</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
@if (user.IsTenantAdmin)
|
||||
{
|
||||
<MudChip T="string" Color="Color.Primary" Size="Size.Small" Variant="Variant.Text" Icon="@Icons.Material.Filled.AdminPanelSettings">Admin</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Color="Color.Default" Size="Size.Small" Variant="Variant.Text" Icon="@Icons.Material.Filled.Person">Mitarbeiter</MudChip>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
}
|
||||
</MudPaper>
|
||||
</td>
|
||||
</MudTr>
|
||||
}
|
||||
</ChildRowContent>
|
||||
</MudTable>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@code {
|
||||
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,17 +0,0 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using System.Security.Claims
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using timetracker.Client
|
||||
@using timetracker.Client.Components
|
||||
@using timetracker.Client.Components.Layout
|
||||
@using timetracker.Client.Services
|
||||
@using timetracker.Shared
|
||||
@using MudBlazor
|
||||
@@ -1,27 +0,0 @@
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||
using MudBlazor.Services;
|
||||
using timetracker.Client.Services;
|
||||
using timetracker.Shared;
|
||||
|
||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
|
||||
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
|
||||
|
||||
builder.Services.AddMudServices();
|
||||
|
||||
builder.Services.AddAuthorizationCore(options =>
|
||||
{
|
||||
options.AddPolicy("AdminOnly", policy =>
|
||||
policy.RequireClaim(System.Security.Claims.ClaimTypes.Name, "marc"));
|
||||
});
|
||||
|
||||
builder.Services.AddScoped<HostAuthenticationStateProvider>();
|
||||
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<HostAuthenticationStateProvider>());
|
||||
|
||||
builder.Services.AddScoped<IAuthService, ClientAuthService>();
|
||||
builder.Services.AddScoped<ITimetrackerService, ClientTimetrackerService>();
|
||||
builder.Services.AddScoped<IHolidayService, ClientHolidayService>();
|
||||
builder.Services.AddScoped<IUserNotificationService, UserNotificationService>();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
|
||||
"applicationUrl": "http://localhost:5016",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
|
||||
"applicationUrl": "https://localhost:7270;http://localhost:5016",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using timetracker.Shared;
|
||||
|
||||
namespace timetracker.Client.Services;
|
||||
|
||||
public class ClientAuthService : IAuthService
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly AuthenticationStateProvider _authStateProvider;
|
||||
|
||||
public ClientAuthService(HttpClient http, AuthenticationStateProvider authStateProvider)
|
||||
{
|
||||
_http = http;
|
||||
_authStateProvider = authStateProvider;
|
||||
}
|
||||
|
||||
private HostAuthenticationStateProvider HostProvider => (HostAuthenticationStateProvider)_authStateProvider;
|
||||
|
||||
public async Task<User?> LoginAsync(string username, string password)
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync("api/auth/login", new { Username = username, Password = password });
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var userInfo = await response.Content.ReadFromJsonAsync<UserInfo>();
|
||||
if (userInfo != null)
|
||||
{
|
||||
HostProvider.NotifyUserChanged(userInfo);
|
||||
return new User { Id = userInfo.Id, Username = userInfo.Username };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<(User? User, string? Error)> RegisterAsync(string username, string password, string? honeypot = null)
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync("api/auth/register", new { Username = username, Password = password, Honeypot = honeypot });
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var userInfo = await response.Content.ReadFromJsonAsync<UserInfo>();
|
||||
if (userInfo != null)
|
||||
{
|
||||
HostProvider.NotifyUserChanged(userInfo);
|
||||
return (new User { Id = userInfo.Id, Username = userInfo.Username }, null);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync();
|
||||
return (null, error);
|
||||
}
|
||||
return (null, "Registrierung fehlgeschlagen.");
|
||||
}
|
||||
|
||||
public async Task<List<User>> GetAllUsersAsync()
|
||||
{
|
||||
return await _http.GetFromJsonAsync<List<User>>("api/users") ?? [];
|
||||
}
|
||||
|
||||
public async Task DeleteUserAsync(int userId)
|
||||
{
|
||||
await _http.DeleteAsync($"api/users/{userId}");
|
||||
}
|
||||
|
||||
public async Task<string?> RenameUserAsync(int userId, string newUsername)
|
||||
{
|
||||
var response = await _http.PutAsJsonAsync($"api/users/{userId}/rename", new { Username = newUsername });
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<string?> ChangePasswordAsync(int userId, string currentPassword, string newPassword)
|
||||
{
|
||||
var response = await _http.PutAsJsonAsync($"api/users/{userId}/change-password", new { CurrentPassword = currentPassword, NewPassword = newPassword });
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<(Tenant? Tenant, string? Error)> RegisterFirmAsync(RegisterFirmRequest req)
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync("api/auth/register-firm", req);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return (new Tenant { Name = req.CompanyName, Subdomain = req.Subdomain }, null);
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync();
|
||||
return (null, error);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> AssignTenantAsync(int userId, int? tenantId)
|
||||
{
|
||||
var response = await _http.PutAsJsonAsync($"api/users/{userId}/assign-tenant", new AssignTenantRequest(tenantId));
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<string?> ResetPasswordAsync(int userId, string newPassword)
|
||||
{
|
||||
var response = await _http.PutAsJsonAsync($"api/users/{userId}/reset-password", new ResetPasswordRequest(newPassword));
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
using System.Net.Http.Json;
|
||||
using timetracker.Shared;
|
||||
|
||||
namespace timetracker.Client.Services;
|
||||
|
||||
public class ClientHolidayService : IHolidayService
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly Dictionary<(int Year, string StateCode), List<PublicHoliday>> _holidayCache = new();
|
||||
|
||||
public ClientHolidayService(HttpClient http)
|
||||
{
|
||||
_http = http;
|
||||
}
|
||||
|
||||
public async Task<List<PublicHoliday>> GetHolidaysAsync(int year, string? stateCode = null)
|
||||
{
|
||||
var sc = stateCode ?? "";
|
||||
var key = (year, sc);
|
||||
if (_holidayCache.TryGetValue(key, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var url = $"api/holidays?year={year}";
|
||||
if (!string.IsNullOrEmpty(stateCode))
|
||||
{
|
||||
url += $"&stateCode={stateCode}";
|
||||
}
|
||||
var result = await _http.GetFromJsonAsync<List<PublicHoliday>>(url) ?? [];
|
||||
_holidayCache[key] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<(bool Success, string Message)> FetchAndStoreAsync(int year)
|
||||
{
|
||||
var response = await _http.PostAsync($"api/holidays/fetch/{year}", null);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var result = await response.Content.ReadFromJsonAsync<FetchResponse>();
|
||||
if (result != null)
|
||||
{
|
||||
// Invalidate the cache for this year
|
||||
var keysToRemove = _holidayCache.Keys.Where(k => k.Year == year).ToList();
|
||||
foreach (var k in keysToRemove)
|
||||
{
|
||||
_holidayCache.Remove(k);
|
||||
}
|
||||
return (result.Success, result.Message);
|
||||
}
|
||||
}
|
||||
return (false, "Fehler beim Abrufen der Feiertage.");
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id)
|
||||
{
|
||||
await _http.DeleteAsync($"api/holidays/{id}");
|
||||
// Invalidate all caches since we deleted a holiday
|
||||
_holidayCache.Clear();
|
||||
}
|
||||
|
||||
private class FetchResponse
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
using System.Net.Http.Json;
|
||||
using timetracker.Shared;
|
||||
|
||||
namespace timetracker.Client.Services;
|
||||
|
||||
public class ClientTimetrackerService : ITimetrackerService
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private AppSettings? _cachedSettings;
|
||||
|
||||
public ClientTimetrackerService(HttpClient http)
|
||||
{
|
||||
_http = http;
|
||||
}
|
||||
|
||||
public async Task<List<WorkDay>> GetWeekAsync(int userId, DateOnly monday)
|
||||
{
|
||||
return await _http.GetFromJsonAsync<List<WorkDay>>($"api/tracker/week?monday={monday:yyyy-MM-dd}") ?? [];
|
||||
}
|
||||
|
||||
public async Task UpsertWorkDayAsync(WorkDay workDay)
|
||||
{
|
||||
await _http.PostAsJsonAsync("api/tracker/workday", workDay);
|
||||
}
|
||||
|
||||
public async Task<AppSettings> GetSettingsAsync(int userId)
|
||||
{
|
||||
if (_cachedSettings != null && _cachedSettings.UserId == userId)
|
||||
{
|
||||
return _cachedSettings;
|
||||
}
|
||||
var settings = await _http.GetFromJsonAsync<AppSettings>("api/tracker/settings");
|
||||
_cachedSettings = settings ?? new AppSettings { UserId = userId };
|
||||
return _cachedSettings;
|
||||
}
|
||||
|
||||
public async Task SaveSettingsAsync(AppSettings settings)
|
||||
{
|
||||
await _http.PostAsJsonAsync("api/tracker/settings", settings);
|
||||
_cachedSettings = settings;
|
||||
}
|
||||
|
||||
public async Task<List<VacationDay>> GetVacationDaysAsync(int userId, int year)
|
||||
{
|
||||
return await _http.GetFromJsonAsync<List<VacationDay>>($"api/tracker/vacation/{year}") ?? [];
|
||||
}
|
||||
|
||||
public async Task AddVacationDayAsync(VacationDay vacationDay)
|
||||
{
|
||||
await _http.PostAsJsonAsync("api/tracker/vacation", vacationDay);
|
||||
}
|
||||
|
||||
public async Task RemoveVacationDayAsync(int userId, int id)
|
||||
{
|
||||
await _http.DeleteAsync($"api/tracker/vacation/{id}");
|
||||
}
|
||||
|
||||
public async Task<TimeSpan> GetTotalOvertimeAsync(int userId, AppSettings settings)
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync("api/tracker/overtime", settings);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var hours = await response.Content.ReadFromJsonAsync<double>();
|
||||
return TimeSpan.FromHours(hours);
|
||||
}
|
||||
return TimeSpan.Zero;
|
||||
}
|
||||
|
||||
public async Task<List<WorkDay>> GetMonthAsync(int userId, int year, int month)
|
||||
{
|
||||
return await _http.GetFromJsonAsync<List<WorkDay>>($"api/tracker/month?year={year}&month={month}") ?? [];
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
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)));
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using timetracker.Shared;
|
||||
|
||||
namespace timetracker.Client.Services;
|
||||
|
||||
public class UserNotificationService : IUserNotificationService, IAsyncDisposable
|
||||
{
|
||||
private readonly HubConnection _hubConnection;
|
||||
|
||||
public event Func<Task>? OnUsersChanged;
|
||||
public event Func<int, Task>? OnUserDeleted;
|
||||
|
||||
public UserNotificationService(NavigationManager navigationManager)
|
||||
{
|
||||
_hubConnection = new HubConnectionBuilder()
|
||||
.WithUrl(navigationManager.ToAbsoluteUri("/hubs/notifications"))
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_hubConnection.On("UsersChanged", async () =>
|
||||
{
|
||||
if (OnUsersChanged != null)
|
||||
await OnUsersChanged.Invoke();
|
||||
});
|
||||
|
||||
_hubConnection.On<int>("UserDeleted", async (userId) =>
|
||||
{
|
||||
if (OnUserDeleted != null)
|
||||
await OnUserDeleted.Invoke(userId);
|
||||
});
|
||||
|
||||
// Start connection asynchronously
|
||||
_ = StartHubConnectionAsync();
|
||||
}
|
||||
|
||||
private async Task StartHubConnectionAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _hubConnection.StartAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"SignalR Connection Error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_hubConnection != null)
|
||||
{
|
||||
await _hubConnection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.AspNetCore.Components.WebAssembly.Http
|
||||
@using Microsoft.JSInterop
|
||||
@using timetracker.Client
|
||||
@using timetracker.Client.Components
|
||||
@using timetracker.Client.Components.Layout
|
||||
@using timetracker.Shared
|
||||
@using MudBlazor
|
||||
@@ -1,22 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.5" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.8" />
|
||||
<PackageReference Include="MudBlazor" Version="9.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\timetracker.Shared\timetracker.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 61 KiB |
@@ -1,53 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<ResourcePreloader />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
|
||||
<link rel="stylesheet" href="_content/MudBlazor/MudBlazor.min.css" />
|
||||
<link rel="stylesheet" href="@Assets["app.css"]" />
|
||||
<link rel="stylesheet" href="@Assets["timetracker.Client.styles.css"]" />
|
||||
<ImportMap />
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||
<link rel="alternate icon" type="image/png" href="favicon.png" />
|
||||
<style>
|
||||
.onboarding-active-target {
|
||||
position: relative !important;
|
||||
z-index: 10000 !important;
|
||||
box-shadow: 0 0 0 9999px rgba(15, 23, 42, 0.75), 0 0 15px rgba(14, 165, 233, 0.5) !important;
|
||||
pointer-events: none !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
</style>
|
||||
<HeadOutlet @rendermode="InteractiveWebAssembly" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes />
|
||||
<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,271 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using timetracker.Shared;
|
||||
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class AuthService(
|
||||
IDbContextFactory<TimetrackerDbContext> factory,
|
||||
UserNotificationService notifier,
|
||||
ITenantProvider tenantProvider) : IAuthService
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
using ClosedXML.Excel;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using timetracker.Shared;
|
||||
|
||||
namespace timetracker.Data;
|
||||
|
||||
public static class ExcelExporter
|
||||
{
|
||||
public static async Task<byte[]> GenerateExcelExportAsync(
|
||||
int userId,
|
||||
DateOnly startDate,
|
||||
DateOnly endDate,
|
||||
string title,
|
||||
ITimetrackerService trackerService,
|
||||
TimetrackerDbContext db)
|
||||
{
|
||||
var settings = await trackerService.GetSettingsAsync(userId);
|
||||
|
||||
var workDays = await db.WorkDays
|
||||
.Include(w => w.Breaks)
|
||||
.Where(w => w.UserId == userId && w.Date >= startDate && w.Date <= endDate)
|
||||
.ToDictionaryAsync(w => w.Date);
|
||||
|
||||
var holidaysList = await db.PublicHolidays
|
||||
.Where(h => h.Date >= startDate && h.Date <= endDate)
|
||||
.ToListAsync();
|
||||
|
||||
var state = settings.GermanState;
|
||||
var holidayMap = holidaysList
|
||||
.Where(h => string.IsNullOrEmpty(h.Counties) || (!string.IsNullOrEmpty(state) && h.Counties.Split(',').Contains(state)))
|
||||
.ToDictionary(h => h.Date, h => h.Name);
|
||||
|
||||
var vacationMap = await db.VacationDays
|
||||
.Where(v => v.UserId == userId && v.Date >= startDate && v.Date <= endDate)
|
||||
.ToDictionaryAsync(v => v.Date, v => v.Note ?? "Urlaub");
|
||||
|
||||
var user = await db.Users.FindAsync(userId);
|
||||
var userName = user != null ? user.Username : "Mitarbeiter";
|
||||
|
||||
var rows = new List<ExportRow>();
|
||||
|
||||
for (var date = startDate; date <= endDate; date = date.AddDays(1))
|
||||
{
|
||||
bool isWorkDay = settings.IsWorkDay(date.DayOfWeek);
|
||||
bool isHoliday = holidayMap.ContainsKey(date);
|
||||
bool isVacation = vacationMap.ContainsKey(date);
|
||||
|
||||
double targetHours = (isWorkDay && !isHoliday && !isVacation) ? settings.DailyTargetHours : 0.0;
|
||||
|
||||
string status = "Wochenende";
|
||||
if (isHoliday) status = "Feiertag";
|
||||
else if (isVacation) status = "Urlaub";
|
||||
else if (isWorkDay) status = "Arbeitstag";
|
||||
|
||||
TimeSpan? startTime = null;
|
||||
TimeSpan? endTime = null;
|
||||
TimeSpan breakTime = TimeSpan.Zero;
|
||||
double actualHours = 0.0;
|
||||
|
||||
string note = "";
|
||||
if (isHoliday) note = holidayMap[date];
|
||||
else if (isVacation) note = vacationMap[date];
|
||||
|
||||
if (workDays.TryGetValue(date, out var wd))
|
||||
{
|
||||
if (wd.StartTime.HasValue) startTime = wd.StartTime.Value.ToTimeSpan();
|
||||
if (wd.EndTime.HasValue) endTime = wd.EndTime.Value.ToTimeSpan();
|
||||
|
||||
if (startTime.HasValue && endTime.HasValue)
|
||||
{
|
||||
var gross = endTime.Value - startTime.Value;
|
||||
if (gross > TimeSpan.Zero)
|
||||
{
|
||||
var breakTotal = wd.Breaks
|
||||
.Where(b => b.StartTime.HasValue && b.EndTime.HasValue && b.EndTime > b.StartTime)
|
||||
.Aggregate(TimeSpan.Zero, (s, b) =>
|
||||
s + (b.EndTime!.Value.ToTimeSpan() - b.StartTime!.Value.ToTimeSpan()));
|
||||
|
||||
var minMinutes = gross.TotalHours >= 9.0 ? 45 : 30;
|
||||
var minBreak = TimeSpan.FromMinutes(Math.Max(minMinutes, settings.MinimumBreakMinutes));
|
||||
var finalBreak = breakTotal > minBreak ? breakTotal : minBreak;
|
||||
|
||||
breakTime = finalBreak;
|
||||
actualHours = (gross - finalBreak).TotalHours;
|
||||
if (actualHours < 0) actualHours = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows.Add(new ExportRow
|
||||
{
|
||||
Date = date,
|
||||
Status = status,
|
||||
StartTime = startTime,
|
||||
EndTime = endTime,
|
||||
BreakTime = breakTime,
|
||||
ActualHours = actualHours,
|
||||
TargetHours = targetHours,
|
||||
DiffHours = actualHours - targetHours,
|
||||
Note = note
|
||||
});
|
||||
}
|
||||
|
||||
return ExportToExcel(title, userName, startDate, endDate, rows);
|
||||
}
|
||||
|
||||
public static byte[] ExportToExcel(
|
||||
string title,
|
||||
string userName,
|
||||
DateOnly startDate,
|
||||
DateOnly endDate,
|
||||
List<ExportRow> rows)
|
||||
{
|
||||
using var workbook = new XLWorkbook();
|
||||
var worksheet = workbook.Worksheets.Add("Arbeitszeiten");
|
||||
|
||||
// Gitterlinien explizit einblenden
|
||||
worksheet.ShowGridLines = true;
|
||||
|
||||
// Title Block
|
||||
worksheet.Cell("A1").Value = title;
|
||||
worksheet.Cell("A1").Style.Font.Bold = true;
|
||||
worksheet.Cell("A1").Style.Font.FontSize = 16;
|
||||
worksheet.Cell("A1").Style.Font.FontColor = XLColor.FromHtml("#0F172A"); // Slate 900
|
||||
|
||||
// Info Block
|
||||
worksheet.Cell("A2").Value = $"Mitarbeiter: {userName}";
|
||||
worksheet.Cell("A2").Style.Font.FontSize = 11;
|
||||
worksheet.Cell("A2").Style.Font.Italic = true;
|
||||
|
||||
worksheet.Cell("A3").Value = $"Zeitraum: {startDate:dd.MM.yyyy} bis {endDate:dd.MM.yyyy}";
|
||||
worksheet.Cell("A3").Style.Font.FontSize = 11;
|
||||
worksheet.Cell("A3").Style.Font.Italic = true;
|
||||
|
||||
// Tabellenüberschriften (Zeile 5)
|
||||
string[] headers = {
|
||||
"Datum", "Wochentag", "Status", "Beginn", "Ende",
|
||||
"Pause", "Netto (Ist)", "Soll-Stunden", "Differenz (Gleitzeit)", "Notiz / Feiertag / Urlaub"
|
||||
};
|
||||
|
||||
for (int i = 0; i < headers.Length; i++)
|
||||
{
|
||||
var cell = worksheet.Cell(5, i + 1);
|
||||
cell.Value = headers[i];
|
||||
cell.Style.Font.Bold = true;
|
||||
cell.Style.Font.FontColor = XLColor.White;
|
||||
cell.Style.Fill.BackgroundColor = XLColor.FromHtml("#0EA5E9"); // Primary color (Sky 500)
|
||||
cell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
|
||||
}
|
||||
|
||||
// Datenzeilen
|
||||
int currentRow = 6;
|
||||
var deCulture = new CultureInfo("de-DE");
|
||||
foreach (var r in rows)
|
||||
{
|
||||
worksheet.Cell(currentRow, 1).Value = r.Date.ToString("dd.MM.yyyy");
|
||||
worksheet.Cell(currentRow, 2).Value = r.Date.ToString("dddd", deCulture);
|
||||
worksheet.Cell(currentRow, 3).Value = r.Status;
|
||||
|
||||
if (r.StartTime.HasValue)
|
||||
{
|
||||
worksheet.Cell(currentRow, 4).Value = r.StartTime.Value.ToString(@"hh\:mm");
|
||||
}
|
||||
else
|
||||
{
|
||||
worksheet.Cell(currentRow, 4).Value = "—";
|
||||
}
|
||||
|
||||
if (r.EndTime.HasValue)
|
||||
{
|
||||
worksheet.Cell(currentRow, 5).Value = r.EndTime.Value.ToString(@"hh\:mm");
|
||||
}
|
||||
else
|
||||
{
|
||||
worksheet.Cell(currentRow, 5).Value = "—";
|
||||
}
|
||||
|
||||
worksheet.Cell(currentRow, 6).Value = r.BreakTime.TotalMinutes > 0 ? r.BreakTime.ToString(@"hh\:mm") : "—";
|
||||
|
||||
worksheet.Cell(currentRow, 7).Value = r.ActualHours;
|
||||
worksheet.Cell(currentRow, 7).Style.NumberFormat.Format = "0.00";
|
||||
|
||||
worksheet.Cell(currentRow, 8).Value = r.TargetHours;
|
||||
worksheet.Cell(currentRow, 8).Style.NumberFormat.Format = "0.00";
|
||||
|
||||
worksheet.Cell(currentRow, 9).Value = r.DiffHours;
|
||||
worksheet.Cell(currentRow, 9).Style.NumberFormat.Format = "+0.00;-0.00;0.00";
|
||||
|
||||
if (r.DiffHours > 0)
|
||||
{
|
||||
worksheet.Cell(currentRow, 9).Style.Font.FontColor = XLColor.FromHtml("#16A34A"); // Green 600
|
||||
worksheet.Cell(currentRow, 9).Style.Font.Bold = true;
|
||||
}
|
||||
else if (r.DiffHours < 0)
|
||||
{
|
||||
worksheet.Cell(currentRow, 9).Style.Font.FontColor = XLColor.FromHtml("#DC2626"); // Red 600
|
||||
worksheet.Cell(currentRow, 9).Style.Font.Bold = true;
|
||||
}
|
||||
|
||||
worksheet.Cell(currentRow, 10).Value = r.Note;
|
||||
|
||||
// Zebra-Streifen
|
||||
if (currentRow % 2 == 0)
|
||||
{
|
||||
for (int col = 1; col <= headers.Length; col++)
|
||||
{
|
||||
worksheet.Cell(currentRow, col).Style.Fill.BackgroundColor = XLColor.FromHtml("#F8FAFC"); // Slate 50
|
||||
}
|
||||
}
|
||||
|
||||
// Rahmenlinien
|
||||
for (int col = 1; col <= headers.Length; col++)
|
||||
{
|
||||
worksheet.Cell(currentRow, col).Style.Border.BottomBorder = XLBorderStyleValues.Thin;
|
||||
worksheet.Cell(currentRow, col).Style.Border.BottomBorderColor = XLColor.FromHtml("#E2E8F0");
|
||||
}
|
||||
|
||||
currentRow++;
|
||||
}
|
||||
|
||||
// Summenzeile
|
||||
currentRow++;
|
||||
|
||||
worksheet.Cell(currentRow, 6).Value = "Gesamt:";
|
||||
worksheet.Cell(currentRow, 6).Style.Font.Bold = true;
|
||||
worksheet.Cell(currentRow, 6).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Right;
|
||||
|
||||
var actualSumRange = $"G6:G{currentRow - 2}";
|
||||
worksheet.Cell(currentRow, 7).FormulaA1 = $"SUM({actualSumRange})";
|
||||
worksheet.Cell(currentRow, 7).Style.Font.Bold = true;
|
||||
worksheet.Cell(currentRow, 7).Style.NumberFormat.Format = "0.00";
|
||||
|
||||
var targetSumRange = $"H6:H{currentRow - 2}";
|
||||
worksheet.Cell(currentRow, 8).FormulaA1 = $"SUM({targetSumRange})";
|
||||
worksheet.Cell(currentRow, 8).Style.Font.Bold = true;
|
||||
worksheet.Cell(currentRow, 8).Style.NumberFormat.Format = "0.00";
|
||||
|
||||
var diffSumRange = $"I6:I{currentRow - 2}";
|
||||
worksheet.Cell(currentRow, 9).FormulaA1 = $"SUM({diffSumRange})";
|
||||
worksheet.Cell(currentRow, 9).Style.Font.Bold = true;
|
||||
worksheet.Cell(currentRow, 9).Style.NumberFormat.Format = "+0.00;-0.00;0.00";
|
||||
|
||||
// Summenrahmen
|
||||
for (int col = 6; col <= 9; col++)
|
||||
{
|
||||
worksheet.Cell(currentRow, col).Style.Border.TopBorder = XLBorderStyleValues.Thin;
|
||||
worksheet.Cell(currentRow, col).Style.Border.TopBorderColor = XLColor.Black;
|
||||
worksheet.Cell(currentRow, col).Style.Border.BottomBorder = XLBorderStyleValues.Double;
|
||||
worksheet.Cell(currentRow, col).Style.Border.BottomBorderColor = XLColor.Black;
|
||||
}
|
||||
|
||||
// Spaltenbreiten automatisch anpassen
|
||||
worksheet.Columns().AdjustToContents();
|
||||
|
||||
using var memoryStream = new MemoryStream();
|
||||
workbook.SaveAs(memoryStream);
|
||||
return memoryStream.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public class ExportRow
|
||||
{
|
||||
public DateOnly Date { get; set; }
|
||||
public string Status { get; set; } = "";
|
||||
public TimeSpan? StartTime { get; set; }
|
||||
public TimeSpan? EndTime { get; set; }
|
||||
public TimeSpan BreakTime { get; set; }
|
||||
public double ActualHours { get; set; }
|
||||
public double TargetHours { get; set; }
|
||||
public double DiffHours { get; set; }
|
||||
public string Note { get; set; } = "";
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
using System.Threading.Tasks;
|
||||
using timetracker.Shared;
|
||||
|
||||
namespace timetracker.Data;
|
||||
|
||||
public interface ITenantProvider
|
||||
{
|
||||
int? TenantId { get; }
|
||||
Task<Tenant?> GetCurrentTenantAsync();
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace timetracker.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMultiTenancy : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "TenantId",
|
||||
table: "WorkDays",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "TenantId",
|
||||
table: "VacationDays",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "TenantId",
|
||||
table: "Users",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "TenantId",
|
||||
table: "AppSettings",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Tenants",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Subdomain = table.Column<string>(type: "TEXT", nullable: false),
|
||||
IsApproved = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Tenants", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Users_TenantId",
|
||||
table: "Users",
|
||||
column: "TenantId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Tenants_Subdomain",
|
||||
table: "Tenants",
|
||||
column: "Subdomain",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Users_Tenants_TenantId",
|
||||
table: "Users",
|
||||
column: "TenantId",
|
||||
principalTable: "Tenants",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Users_Tenants_TenantId",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Tenants");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Users_TenantId",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TenantId",
|
||||
table: "WorkDays");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TenantId",
|
||||
table: "VacationDays");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TenantId",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TenantId",
|
||||
table: "AppSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
-265
@@ -1,265 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using timetracker.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace timetracker.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(TimetrackerDbContext))]
|
||||
[Migration("20260624210436_AddTenantBranding")]
|
||||
partial class AddTenantBranding
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.AppSettings", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double>("DailyTargetHours")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<DateOnly?>("FlexTimeStartDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<double>("FlexTimeStartingBalanceHours")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("GermanState")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MinimumBreakMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VacationDaysPerYear")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkFriday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkMonday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkSaturday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkSunday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkThursday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkTuesday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkWednesday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AppSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<TimeOnly?>("EndTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<TimeOnly?>("StartTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("WorkDayId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkDayId");
|
||||
|
||||
b.ToTable("BreakEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.PublicHoliday", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Counties")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("PublicHolidays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.Tenant", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsApproved")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PrimaryColor")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SecondaryColor")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Subdomain")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Subdomain")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Tenants");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsTenantAdmin")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordSalt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.VacationDay", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("VacationDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<TimeOnly?>("EndTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<TimeOnly?>("StartTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("WorkDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
|
||||
{
|
||||
b.HasOne("timetracker.Shared.WorkDay", "WorkDay")
|
||||
.WithMany("Breaks")
|
||||
.HasForeignKey("WorkDayId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("WorkDay");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.User", b =>
|
||||
{
|
||||
b.HasOne("timetracker.Shared.Tenant", "Tenant")
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId");
|
||||
|
||||
b.Navigation("Tenant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
|
||||
{
|
||||
b.Navigation("Breaks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace timetracker.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTenantBranding : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsTenantAdmin",
|
||||
table: "Users",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PrimaryColor",
|
||||
table: "Tenants",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SecondaryColor",
|
||||
table: "Tenants",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsTenantAdmin",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PrimaryColor",
|
||||
table: "Tenants");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SecondaryColor",
|
||||
table: "Tenants");
|
||||
}
|
||||
}
|
||||
}
|
||||
-265
@@ -1,265 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using timetracker.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace timetracker.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(TimetrackerDbContext))]
|
||||
[Migration("20260625184756_PendingChanges")]
|
||||
partial class PendingChanges
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.AppSettings", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double>("DailyTargetHours")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<DateOnly?>("FlexTimeStartDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<double>("FlexTimeStartingBalanceHours")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("GermanState")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MinimumBreakMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VacationDaysPerYear")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkFriday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkMonday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkSaturday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkSunday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkThursday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkTuesday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkWednesday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AppSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<TimeOnly?>("EndTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<TimeOnly?>("StartTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("WorkDayId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkDayId");
|
||||
|
||||
b.ToTable("BreakEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.PublicHoliday", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Counties")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("PublicHolidays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.Tenant", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsApproved")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PrimaryColor")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SecondaryColor")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Subdomain")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Subdomain")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Tenants");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsTenantAdmin")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordSalt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.VacationDay", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("VacationDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<TimeOnly?>("EndTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<TimeOnly?>("StartTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("WorkDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
|
||||
{
|
||||
b.HasOne("timetracker.Shared.WorkDay", "WorkDay")
|
||||
.WithMany("Breaks")
|
||||
.HasForeignKey("WorkDayId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("WorkDay");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.User", b =>
|
||||
{
|
||||
b.HasOne("timetracker.Shared.Tenant", "Tenant")
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId");
|
||||
|
||||
b.Navigation("Tenant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
|
||||
{
|
||||
b.Navigation("Breaks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace timetracker.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class PendingChanges : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using timetracker.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace timetracker.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(TimetrackerDbContext))]
|
||||
partial class TimetrackerDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.AppSettings", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double>("DailyTargetHours")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<DateOnly?>("FlexTimeStartDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<double>("FlexTimeStartingBalanceHours")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("GermanState")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MinimumBreakMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VacationDaysPerYear")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkFriday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkMonday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkSaturday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkSunday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkThursday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkTuesday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkWednesday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AppSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<TimeOnly?>("EndTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<TimeOnly?>("StartTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("WorkDayId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkDayId");
|
||||
|
||||
b.ToTable("BreakEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.PublicHoliday", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Counties")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("PublicHolidays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.Tenant", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsApproved")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PrimaryColor")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SecondaryColor")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Subdomain")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Subdomain")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Tenants");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsTenantAdmin")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordSalt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.VacationDay", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("VacationDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<TimeOnly?>("EndTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<TimeOnly?>("StartTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("WorkDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
|
||||
{
|
||||
b.HasOne("timetracker.Shared.WorkDay", "WorkDay")
|
||||
.WithMany("Breaks")
|
||||
.HasForeignKey("WorkDayId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("WorkDay");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.User", b =>
|
||||
{
|
||||
b.HasOne("timetracker.Shared.Tenant", "Tenant")
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId");
|
||||
|
||||
b.Navigation("Tenant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
|
||||
{
|
||||
b.Navigation("Breaks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class NotificationHub : Hub
|
||||
{
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using timetracker.Shared;
|
||||
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class TenantProvider : ITenantProvider
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly IDbContextFactory<TimetrackerDbContext> _dbContextFactory;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public TenantProvider(
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IDbContextFactory<TimetrackerDbContext> dbContextFactory,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public int? TenantId
|
||||
{
|
||||
get
|
||||
{
|
||||
var httpContext = _httpContextAccessor.HttpContext;
|
||||
if (httpContext == null) return null;
|
||||
|
||||
if (httpContext.Items.TryGetValue("TenantId", out var cachedId))
|
||||
{
|
||||
return (int?)cachedId;
|
||||
}
|
||||
|
||||
// Resolve and cache in HttpContext.Items
|
||||
ResolveTenant();
|
||||
|
||||
if (httpContext.Items.TryGetValue("TenantId", out cachedId))
|
||||
{
|
||||
return (int?)cachedId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<Tenant?> GetCurrentTenantAsync()
|
||||
{
|
||||
var httpContext = _httpContextAccessor.HttpContext;
|
||||
if (httpContext == null) return Task.FromResult<Tenant?>(null);
|
||||
|
||||
if (httpContext.Items.TryGetValue("CurrentTenant", out var cachedTenant))
|
||||
{
|
||||
return Task.FromResult((Tenant?)cachedTenant);
|
||||
}
|
||||
|
||||
ResolveTenant();
|
||||
|
||||
if (httpContext.Items.TryGetValue("CurrentTenant", out cachedTenant))
|
||||
{
|
||||
return Task.FromResult((Tenant?)cachedTenant);
|
||||
}
|
||||
|
||||
return Task.FromResult<Tenant?>(null);
|
||||
}
|
||||
|
||||
private void ResolveTenant()
|
||||
{
|
||||
var httpContext = _httpContextAccessor.HttpContext;
|
||||
if (httpContext == null) return;
|
||||
|
||||
// Prevent double resolution in the same request
|
||||
if (httpContext.Items.ContainsKey("TenantResolved")) return;
|
||||
httpContext.Items["TenantResolved"] = true;
|
||||
|
||||
var host = httpContext.Request.Host.Host;
|
||||
var subdomain = GetSubdomain(host);
|
||||
|
||||
if (!string.IsNullOrEmpty(subdomain))
|
||||
{
|
||||
using var db = _dbContextFactory.CreateDbContext();
|
||||
var tenant = db.Tenants
|
||||
.FirstOrDefault(t => t.Subdomain.ToLower() == subdomain.ToLower());
|
||||
|
||||
if (tenant != null)
|
||||
{
|
||||
httpContext.Items["CurrentTenant"] = tenant;
|
||||
httpContext.Items["TenantId"] = tenant.Id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string? GetSubdomain(string host)
|
||||
{
|
||||
var baseDomain = _configuration["TenantSettings:BaseDomain"] ?? "localhost";
|
||||
|
||||
// Ignore port if included in host
|
||||
var hostName = host.Split(':')[0];
|
||||
|
||||
if (hostName.Equals(baseDomain, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null; // Main domain
|
||||
}
|
||||
|
||||
if (hostName.EndsWith("." + baseDomain, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return hostName.Substring(0, hostName.Length - baseDomain.Length - 1);
|
||||
}
|
||||
|
||||
// Fallback for localhost testing
|
||||
if (baseDomain.Equals("localhost", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var parts = hostName.Split('.');
|
||||
if (parts.Length > 1 && !hostName.Equals("localhost", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return parts[0];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using timetracker.Shared;
|
||||
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class TimetrackerDbContext : DbContext
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private ITenantProvider? _tenantProvider;
|
||||
private ITenantProvider TenantProvider => _tenantProvider ??= _serviceProvider.GetRequiredService<ITenantProvider>();
|
||||
|
||||
public TimetrackerDbContext(
|
||||
DbContextOptions<TimetrackerDbContext> options,
|
||||
IServiceProvider serviceProvider) : base(options)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
public DbSet<Tenant> Tenants => Set<Tenant>();
|
||||
public DbSet<User> Users => Set<User>();
|
||||
public DbSet<WorkDay> WorkDays => Set<WorkDay>();
|
||||
public DbSet<BreakEntry> BreakEntries => Set<BreakEntry>();
|
||||
public DbSet<AppSettings> AppSettings => Set<AppSettings>();
|
||||
public DbSet<VacationDay> VacationDays => Set<VacationDay>();
|
||||
public DbSet<PublicHoliday> PublicHolidays => Set<PublicHoliday>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// Subdomain index unique
|
||||
modelBuilder.Entity<Tenant>()
|
||||
.HasIndex(t => t.Subdomain)
|
||||
.IsUnique();
|
||||
|
||||
// Global Query Filters (scopes database views by resolved TenantId)
|
||||
modelBuilder.Entity<User>().HasQueryFilter(u => u.TenantId == TenantProvider.TenantId);
|
||||
modelBuilder.Entity<WorkDay>().HasQueryFilter(w => w.TenantId == TenantProvider.TenantId);
|
||||
modelBuilder.Entity<VacationDay>().HasQueryFilter(v => v.TenantId == TenantProvider.TenantId);
|
||||
modelBuilder.Entity<AppSettings>().HasQueryFilter(s => s.TenantId == TenantProvider.TenantId);
|
||||
}
|
||||
|
||||
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantId = TenantProvider.TenantId;
|
||||
|
||||
if (tenantId.HasValue)
|
||||
{
|
||||
foreach (var entry in ChangeTracker.Entries())
|
||||
{
|
||||
if (entry.State == EntityState.Added)
|
||||
{
|
||||
var tenantIdProp = entry.Entity.GetType().GetProperty("TenantId");
|
||||
if (tenantIdProp != null && tenantIdProp.CanWrite)
|
||||
{
|
||||
var currentValue = tenantIdProp.GetValue(entry.Entity);
|
||||
if (currentValue == null || (currentValue is int val && val == 0))
|
||||
{
|
||||
tenantIdProp.SetValue(entry.Entity, tenantId.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return base.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using timetracker.Shared;
|
||||
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class UserNotificationService : IUserNotificationService
|
||||
{
|
||||
private readonly IHubContext<NotificationHub> _hubContext;
|
||||
|
||||
public event Func<Task>? OnUsersChanged;
|
||||
public event Func<int, Task>? OnUserDeleted;
|
||||
|
||||
public UserNotificationService(IHubContext<NotificationHub> hubContext)
|
||||
{
|
||||
_hubContext = hubContext;
|
||||
}
|
||||
|
||||
public async Task NotifyUsersChangedAsync()
|
||||
{
|
||||
// Broadcast via SignalR to all clients
|
||||
await _hubContext.Clients.All.SendAsync("UsersChanged");
|
||||
|
||||
// Also trigger locally if there are server-side subscribers
|
||||
if (OnUsersChanged != null)
|
||||
await OnUsersChanged.Invoke();
|
||||
}
|
||||
|
||||
public async Task NotifyUserDeletedAsync(int userId)
|
||||
{
|
||||
// Broadcast via SignalR to all clients
|
||||
await _hubContext.Clients.All.SendAsync("UserDeleted", userId);
|
||||
|
||||
// Also trigger locally if there are server-side subscribers
|
||||
if (OnUserDeleted != null)
|
||||
await OnUserDeleted.Invoke(userId);
|
||||
}
|
||||
}
|
||||
@@ -1,828 +0,0 @@
|
||||
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)
|
||||
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
|
||||
}
|
||||
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}")
|
||||
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
|
||||
}
|
||||
|
||||
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))
|
||||
{
|
||||
try
|
||||
{
|
||||
var conn = db.Database.GetDbConnection();
|
||||
if (conn.State != System.Data.ConnectionState.Open)
|
||||
{
|
||||
await conn.OpenAsync();
|
||||
}
|
||||
|
||||
// Check if AppSettings table already exists
|
||||
using (var cmdCheck = conn.CreateCommand())
|
||||
{
|
||||
cmdCheck.CommandText = "SELECT EXISTS (SELECT FROM pg_tables WHERE schemaname = 'public' AND tablename = 'AppSettings');";
|
||||
var appSettingsExists = (bool)(await cmdCheck.ExecuteScalarAsync() ?? false);
|
||||
|
||||
if (appSettingsExists)
|
||||
{
|
||||
// Ensure __EFMigrationsHistory table exists
|
||||
using (var cmdCreateHistory = conn.CreateCommand())
|
||||
{
|
||||
cmdCreateHistory.CommandText = @"
|
||||
CREATE TABLE IF NOT EXISTS ""__EFMigrationsHistory"" (
|
||||
""MigrationId"" character varying(150) NOT NULL,
|
||||
""ProductVersion"" character varying(32) NOT NULL,
|
||||
CONSTRAINT ""PK___EFMigrationsHistory"" PRIMARY KEY (""MigrationId"")
|
||||
);";
|
||||
await cmdCreateHistory.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
// Check if the initial migration is already recorded
|
||||
using (var cmdCheckHistory = conn.CreateCommand())
|
||||
{
|
||||
cmdCheckHistory.CommandText = @"SELECT COUNT(*) FROM ""__EFMigrationsHistory"" WHERE ""MigrationId"" = '20260520133634_Initial';";
|
||||
var historyCount = Convert.ToInt32(await cmdCheckHistory.ExecuteScalarAsync() ?? 0);
|
||||
|
||||
if (historyCount == 0)
|
||||
{
|
||||
// Seed the history table with the migrations that were already created by EnsureCreated in older versions
|
||||
using (var cmdInsertHistory = conn.CreateCommand())
|
||||
{
|
||||
cmdInsertHistory.CommandText = @"
|
||||
INSERT INTO ""__EFMigrationsHistory"" (""MigrationId"", ""ProductVersion"") VALUES
|
||||
('20260520133634_Initial', '10.0.9'),
|
||||
('20260520200000_AddPublicHolidays', '10.0.9'),
|
||||
('20260522081459_AddMultiUser', '10.0.9'),
|
||||
('20260607213215_AddFlexTimeAndHolidayState', '10.0.9');";
|
||||
await cmdInsertHistory.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Fehler beim Vorbereiten der PostgreSQL Migrationshistorie: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
await db.Database.MigrateAsync();
|
||||
|
||||
if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
var conn = db.Database.GetDbConnection();
|
||||
if (conn.State != System.Data.ConnectionState.Open)
|
||||
{
|
||||
await conn.OpenAsync();
|
||||
}
|
||||
|
||||
// Check if column IsTenantAdmin in Users table is of type integer
|
||||
using (var cmdCheckType = conn.CreateCommand())
|
||||
{
|
||||
cmdCheckType.CommandText = @"
|
||||
SELECT data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'Users' AND column_name = 'IsTenantAdmin';";
|
||||
var dataType = await cmdCheckType.ExecuteScalarAsync() as string;
|
||||
if (dataType != null && dataType.Equals("integer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Alter the column type to boolean
|
||||
using (var cmdAlterType = conn.CreateCommand())
|
||||
{
|
||||
cmdAlterType.CommandText = @"
|
||||
ALTER TABLE ""Users""
|
||||
ALTER COLUMN ""IsTenantAdmin"" TYPE boolean
|
||||
USING (""IsTenantAdmin"" <> 0);";
|
||||
await cmdAlterType.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if column IsApproved in Tenants table is of type integer
|
||||
using (var cmdCheckApproved = conn.CreateCommand())
|
||||
{
|
||||
cmdCheckApproved.CommandText = @"
|
||||
SELECT data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'Tenants' AND column_name = 'IsApproved';";
|
||||
var dataType = await cmdCheckApproved.ExecuteScalarAsync() as string;
|
||||
if (dataType != null && dataType.Equals("integer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Alter the column type to boolean
|
||||
using (var cmdAlterApproved = conn.CreateCommand())
|
||||
{
|
||||
cmdAlterApproved.CommandText = @"
|
||||
ALTER TABLE ""Tenants""
|
||||
ALTER COLUMN ""IsApproved"" TYPE boolean
|
||||
USING (""IsApproved"" <> 0);";
|
||||
await cmdAlterApproved.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if column CreatedAt in Tenants table is of type text or character varying
|
||||
using (var cmdCheckCreatedAt = conn.CreateCommand())
|
||||
{
|
||||
cmdCheckCreatedAt.CommandText = @"
|
||||
SELECT data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'Tenants' AND column_name = 'CreatedAt';";
|
||||
var dataType = await cmdCheckCreatedAt.ExecuteScalarAsync() as string;
|
||||
if (dataType != null && (dataType.Equals("text", StringComparison.OrdinalIgnoreCase) || dataType.Equals("character varying", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
// Alter the column type to timestamp without time zone
|
||||
using (var cmdAlterCreatedAt = conn.CreateCommand())
|
||||
{
|
||||
cmdAlterCreatedAt.CommandText = @"
|
||||
ALTER TABLE ""Tenants""
|
||||
ALTER COLUMN ""CreatedAt"" TYPE timestamp without time zone
|
||||
USING (""CreatedAt""::timestamp without time zone);";
|
||||
await cmdAlterCreatedAt.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Fehler beim Konvertieren der PostgreSQL-Spaltentypen (IsTenantAdmin/IsApproved/CreatedAt): {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace timetracker.Shared;
|
||||
|
||||
public record AssignTenantRequest(int? TenantId);
|
||||
@@ -1,14 +0,0 @@
|
||||
namespace timetracker.Shared;
|
||||
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<User?> LoginAsync(string username, string password);
|
||||
Task<List<User>> GetAllUsersAsync();
|
||||
Task DeleteUserAsync(int userId);
|
||||
Task<string?> RenameUserAsync(int userId, string newUsername);
|
||||
Task<string?> ChangePasswordAsync(int userId, string currentPassword, string newPassword);
|
||||
Task<(User? User, string? Error)> RegisterAsync(string username, string password, string? honeypot = null);
|
||||
Task<(Tenant? Tenant, string? Error)> RegisterFirmAsync(RegisterFirmRequest req);
|
||||
Task<string?> AssignTenantAsync(int userId, int? tenantId);
|
||||
Task<string?> ResetPasswordAsync(int userId, string newPassword);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace timetracker.Shared;
|
||||
|
||||
public interface IHolidayService
|
||||
{
|
||||
Task<List<PublicHoliday>> GetHolidaysAsync(int year, string? stateCode = null);
|
||||
Task<(bool Success, string Message)> FetchAndStoreAsync(int year);
|
||||
Task DeleteAsync(int id);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
namespace timetracker.Shared;
|
||||
|
||||
public interface ITimetrackerService
|
||||
{
|
||||
Task<List<WorkDay>> GetWeekAsync(int userId, DateOnly monday);
|
||||
Task UpsertWorkDayAsync(WorkDay workDay);
|
||||
Task<AppSettings> GetSettingsAsync(int userId);
|
||||
Task SaveSettingsAsync(AppSettings settings);
|
||||
Task<List<VacationDay>> GetVacationDaysAsync(int userId, int year);
|
||||
Task AddVacationDayAsync(VacationDay vacationDay);
|
||||
Task RemoveVacationDayAsync(int userId, int id);
|
||||
Task<TimeSpan> GetTotalOvertimeAsync(int userId, AppSettings settings);
|
||||
Task<List<WorkDay>> GetMonthAsync(int userId, int year, int month);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace timetracker.Shared;
|
||||
|
||||
public interface IUserNotificationService
|
||||
{
|
||||
event Func<Task>? OnUsersChanged;
|
||||
event Func<int, Task>? OnUserDeleted;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace timetracker.Shared;
|
||||
|
||||
public record RegisterFirmRequest(
|
||||
string CompanyName,
|
||||
string Subdomain,
|
||||
string AdminUsername,
|
||||
string AdminPassword
|
||||
);
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace timetracker.Shared;
|
||||
|
||||
public record ResetPasswordRequest(string NewPassword);
|
||||
@@ -1,14 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace timetracker.Shared;
|
||||
|
||||
public class Tenant
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string Subdomain { get; set; } = "";
|
||||
public bool IsApproved { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public string PrimaryColor { get; set; } = "#0EA5E9";
|
||||
public string SecondaryColor { get; set; } = "#0EA5E9";
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user