multi-tenant implementierung
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
@inject NavigationManager Nav
|
||||
@inject IUserNotificationService UserNotificationService
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject HttpClient Http
|
||||
@implements IDisposable
|
||||
@using System.Security.Claims
|
||||
|
||||
@@ -12,7 +13,7 @@
|
||||
<MudSnackbarProvider />
|
||||
|
||||
<MudLayout>
|
||||
<MudAppBar Elevation="0" Style="background: #0F172A; border-bottom: 1px solid rgba(255, 255, 255, 0.08);">
|
||||
<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" />
|
||||
@@ -50,9 +51,74 @@
|
||||
private bool _drawerOpen = true;
|
||||
private bool _isDarkMode;
|
||||
|
||||
protected override void OnInitialized()
|
||||
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)
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
}
|
||||
|
||||
::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;
|
||||
@@ -21,7 +22,7 @@
|
||||
|
||||
/* Glassmorphism for the appbar */
|
||||
::deep .mud-appbar {
|
||||
background: rgba(15, 23, 42, 0.85) !important;
|
||||
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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject HttpClient Http
|
||||
<div class="nav-container onboarding-nav-menu">
|
||||
@* --- Brand / Logo Section --- *@
|
||||
<div class="nav-header">
|
||||
@@ -37,6 +38,11 @@
|
||||
<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
|
||||
@@ -61,7 +67,7 @@
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<span class="user-name">@context.User.Identity?.Name</span>
|
||||
<span class="user-status">Online</span>
|
||||
<span class="user-status">@_companyName</span>
|
||||
</div>
|
||||
</div>
|
||||
</NavLink>
|
||||
@@ -93,6 +99,23 @@
|
||||
[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)
|
||||
{
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
}
|
||||
|
||||
::deep .logo-icon {
|
||||
color: #0EA5E9 !important; /* Sky 500 */
|
||||
color: var(--mud-palette-secondary) !important;
|
||||
font-size: 1.6rem !important;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
@@ -52,7 +52,7 @@
|
||||
.brand-text {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
color: #FFFFFF;
|
||||
color: var(--mud-palette-appbar-text);
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
opacity: 1;
|
||||
@@ -104,7 +104,7 @@
|
||||
::deep .custom-nav-link,
|
||||
::deep .custom-nav-link .mud-nav-link-text,
|
||||
::deep .custom-nav-link .mud-nav-link-icon-default {
|
||||
color: #94A3B8 !important; /* Slate 400 */
|
||||
color: var(--mud-palette-drawer-text) !important;
|
||||
}
|
||||
|
||||
/* NavLink hover */
|
||||
@@ -115,15 +115,15 @@
|
||||
::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: #F8FAFC !important; /* Slate 50 */
|
||||
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, rgba(14, 165, 233, 0.15) 0%, rgba(14, 165, 233, 0.04) 100%) !important;
|
||||
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 #0EA5E9 !important;
|
||||
box-shadow: inset 3px 0 0 var(--mud-palette-secondary) !important;
|
||||
}
|
||||
|
||||
::deep .custom-nav-link.active,
|
||||
@@ -132,7 +132,7 @@
|
||||
::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: #0EA5E9 !important; /* Sky 500 */
|
||||
color: var(--mud-palette-secondary) !important;
|
||||
}
|
||||
|
||||
/* Admin link color overrides & active state */
|
||||
@@ -208,7 +208,7 @@
|
||||
::deep .user-avatar {
|
||||
width: 36px !important;
|
||||
height: 36px !important;
|
||||
background: linear-gradient(135deg, #0EA5E9 0%, #0284C7 100%) !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;
|
||||
@@ -222,7 +222,7 @@
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: #10B981; /* Emerald 500 */
|
||||
border: 2px solid #0F172A;
|
||||
border: 2px solid var(--mud-palette-drawer-background);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@
|
||||
.user-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
color: var(--mud-palette-appbar-text);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
@page "/admin/users"
|
||||
@rendermode InteractiveWebAssembly
|
||||
@attribute [Authorize(Policy = "AdminOnly")]
|
||||
@using System.Net.Http.Json
|
||||
@using System.Security.Claims
|
||||
@inject IAuthService AuthService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject IUserNotificationService UserNotificationService
|
||||
@inject HttpClient Http
|
||||
@inject IDialogService DialogService
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>Benutzerverwaltung – Timetracker</PageTitle>
|
||||
@@ -42,6 +46,7 @@ else
|
||||
<HeaderContent>
|
||||
<MudTh><MudTableSortLabel SortBy="new Func<User, object>(u => u.Id)">ID</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortBy="new Func<User, object>(u => u.Username)" InitialDirection="SortDirection.Ascending">Benutzername</MudTableSortLabel></MudTh>
|
||||
<MudTh>Firma / Mandant</MudTh>
|
||||
<MudTh Style="text-align:right">Aktionen</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
@@ -74,6 +79,34 @@ else
|
||||
</MudStack>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
@if (_editUserId == context.Id)
|
||||
{
|
||||
<MudSelect @bind-Value="_editTenantId" Margin="Margin.Dense" T="int?" Label="Firma zuweisen" Variant="Variant.Outlined" Style="max-width:250px">
|
||||
<MudSelectItem T="int?" Value="@((int?)null)">Hauptdomain (System/Global)</MudSelectItem>
|
||||
@foreach (var tenant in _tenants)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)tenant.Id)">@tenant.Name (@tenant.Subdomain)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (context.TenantId.HasValue)
|
||||
{
|
||||
var t = _tenants.FirstOrDefault(x => x.Id == context.TenantId.Value);
|
||||
<MudChip T="string" Color="Color.Primary" Variant="Variant.Outlined" Size="Size.Small">
|
||||
@(t?.Name ?? $"Firma #{context.TenantId}")
|
||||
</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Color="Color.Default" Variant="Variant.Text" Size="Size.Small">
|
||||
Hauptdomain (SuperUser)
|
||||
</MudChip>
|
||||
}
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd Style="text-align:right">
|
||||
@if (_editUserId == context.Id)
|
||||
{
|
||||
@@ -92,6 +125,12 @@ else
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small"
|
||||
OnClick="@(() => StartEdit(context))" />
|
||||
<MudTooltip Text="Passwort zurücksetzen">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.LockReset"
|
||||
Color="Color.Warning"
|
||||
Size="Size.Small"
|
||||
OnClick="@(() => OpenResetPasswordDialog(context))" />
|
||||
</MudTooltip>
|
||||
@if (context.Username != "marc")
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline"
|
||||
@@ -114,9 +153,11 @@ else
|
||||
|
||||
@code {
|
||||
private List<User> _users = [];
|
||||
private List<TenantStats> _tenants = [];
|
||||
private bool _loading = true;
|
||||
private int? _editUserId;
|
||||
private string _editUsername = "";
|
||||
private int? _editTenantId;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -125,13 +166,31 @@ else
|
||||
if (claim == null) return;
|
||||
|
||||
UserNotificationService.OnUsersChanged += RefreshUsers;
|
||||
_users = await AuthService.GetAllUsersAsync();
|
||||
|
||||
try
|
||||
{
|
||||
_users = await AuthService.GetAllUsersAsync();
|
||||
_tenants = await Http.GetFromJsonAsync<List<TenantStats>>("api/superadmin/tenants") ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Fehler beim Laden der Daten: {ex.Message}", Severity.Error);
|
||||
}
|
||||
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task RefreshUsers()
|
||||
{
|
||||
_users = await AuthService.GetAllUsersAsync();
|
||||
try
|
||||
{
|
||||
_tenants = await Http.GetFromJsonAsync<List<TenantStats>>("api/superadmin/tenants") ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore background refresh errors
|
||||
}
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
@@ -144,27 +203,49 @@ else
|
||||
{
|
||||
_editUserId = user.Id;
|
||||
_editUsername = user.Username;
|
||||
_editTenantId = user.TenantId;
|
||||
}
|
||||
|
||||
private void CancelEdit()
|
||||
{
|
||||
_editUserId = null;
|
||||
_editUsername = "";
|
||||
_editTenantId = null;
|
||||
}
|
||||
|
||||
private async Task SaveRename(User user)
|
||||
{
|
||||
var trimmed = _editUsername.Trim();
|
||||
var error = await AuthService.RenameUserAsync(user.Id, trimmed);
|
||||
if (error != null)
|
||||
|
||||
// 1. Rename if username changed
|
||||
if (trimmed != user.Username)
|
||||
{
|
||||
Snackbar.Add(error, Severity.Error);
|
||||
return;
|
||||
var error = await AuthService.RenameUserAsync(user.Id, trimmed);
|
||||
if (error != null)
|
||||
{
|
||||
Snackbar.Add(error, Severity.Error);
|
||||
return;
|
||||
}
|
||||
user.Username = trimmed;
|
||||
}
|
||||
user.Username = trimmed;
|
||||
|
||||
// 2. Move to tenant if tenant ID changed
|
||||
if (_editTenantId != user.TenantId)
|
||||
{
|
||||
var error = await AuthService.AssignTenantAsync(user.Id, _editTenantId);
|
||||
if (error != null)
|
||||
{
|
||||
Snackbar.Add(error, Severity.Error);
|
||||
return;
|
||||
}
|
||||
user.TenantId = _editTenantId;
|
||||
}
|
||||
|
||||
_editUserId = null;
|
||||
_editUsername = "";
|
||||
Snackbar.Add($"Benutzer umbenannt zu \"{trimmed}\".", Severity.Success);
|
||||
_editTenantId = null;
|
||||
|
||||
Snackbar.Add("Benutzerdaten erfolgreich aktualisiert.", Severity.Success);
|
||||
}
|
||||
|
||||
private async Task DeleteUser(User user)
|
||||
@@ -173,4 +254,29 @@ else
|
||||
_users.Remove(user);
|
||||
Snackbar.Add($"Benutzer \"{user.Username}\" gelöscht.", Severity.Info);
|
||||
}
|
||||
|
||||
private async Task OpenResetPasswordDialog(User user)
|
||||
{
|
||||
var parameters = new DialogParameters<ResetPasswordDialog>
|
||||
{
|
||||
{ x => x.Username, user.Username }
|
||||
};
|
||||
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||
var dialog = await DialogService.ShowAsync<ResetPasswordDialog>("Passwort zurücksetzen", parameters, options);
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (result != null && !result.Canceled && result.Data is string newPassword)
|
||||
{
|
||||
var error = await AuthService.ResetPasswordAsync(user.Id, newPassword);
|
||||
if (error != null)
|
||||
{
|
||||
Snackbar.Add(error, Severity.Error);
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Passwort für {user.Username} wurde erfolgreich zurückgesetzt.", Severity.Success);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
<MudStack Spacing="4">
|
||||
|
||||
@* ── Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl" Style="background: #1E293B; color: white;">
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Gavel" Style="color: white; font-size: 2.2rem;" />
|
||||
<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: white; font-weight: 700;">Allgemeine Geschäftsbedingungen (AGB)</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color: rgba(255,255,255,0.72);">
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight: 700;">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>
|
||||
|
||||
@@ -8,13 +8,12 @@
|
||||
<MudStack Spacing="4">
|
||||
|
||||
@* ── Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
||||
Style="background: #1E293B; color:white;">
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
|
||||
<MudIcon Icon="@Icons.Material.Filled.NewReleases" Style="color:white; font-size:2rem" />
|
||||
<MudIcon Icon="@Icons.Material.Filled.NewReleases" Style="color: var(--mud-palette-primary-text); font-size:2rem" />
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.h5" Style="color:white; font-weight:700">Changelog</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">Versionshistorie & Änderungen</MudText>
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700">Changelog</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">Versionshistorie & Änderungen</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@@ -21,29 +21,28 @@ else
|
||||
<MudStack Spacing="3">
|
||||
|
||||
@* ── Wochen-Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl onboarding-week-header"
|
||||
Style="background: #1E293B; color: white;">
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl onboarding-week-header mud-theme-primary">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
|
||||
Style="color:white" Size="Size.Large" OnClick="PrevWeek" />
|
||||
Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="PrevWeek" />
|
||||
<MudStack Spacing="0" AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.h5" Style="color:white; font-weight:700; letter-spacing:0.5px">
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700; letter-spacing:0.5px">
|
||||
@_weekLabel
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
|
||||
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">
|
||||
@_weekSubLabel
|
||||
</MudText>
|
||||
</MudStack>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
|
||||
@if (!IsCurrentWeek)
|
||||
{
|
||||
<MudButton Variant="Variant.Text" Style="color:white"
|
||||
<MudButton Variant="Variant.Text" Style="color: var(--mud-palette-primary-text)"
|
||||
OnClick="GoToCurrentWeek" Size="Size.Small">
|
||||
Heute
|
||||
</MudButton>
|
||||
}
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
|
||||
Style="color:white" Size="Size.Large" OnClick="NextWeek" />
|
||||
Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="NextWeek" />
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
@@ -316,34 +315,34 @@ else
|
||||
|
||||
@* ── Wochensumme ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl onboarding-week-summary"
|
||||
Style="background: #0F172A; color:white;">
|
||||
Style="background: var(--mud-palette-primary-darken); color: var(--mud-palette-primary-text);">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-4">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Summarize" Style="color:rgba(255,255,255,0.8)" />
|
||||
<MudText Typo="Typo.h6" Style="color:white; font-weight:600">Wochensumme</MudText>
|
||||
<MudIcon Icon="@Icons.Material.Filled.Summarize" Style="color: var(--mud-palette-primary-text); opacity: 0.8" />
|
||||
<MudText Typo="Typo.h6" Style="color: var(--mud-palette-primary-text); font-weight:600">Wochensumme</MudText>
|
||||
</MudStack>
|
||||
<MudGrid Spacing="3">
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Brutto</MudText>
|
||||
<MudText Typo="Typo.h5" Style="color:white">@FormatTs(WeekGross)</MudText>
|
||||
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-primary-text); opacity: 0.6">Brutto</MudText>
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text)">@FormatTs(WeekGross)</MudText>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Pausen</MudText>
|
||||
<MudText Typo="Typo.h5" Style="color:white">@FormatTs(WeekBreaks)</MudText>
|
||||
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-primary-text); opacity: 0.6">Pausen</MudText>
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text)">@FormatTs(WeekBreaks)</MudText>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Netto</MudText>
|
||||
<MudText Typo="Typo.h5" Style="color:#90CAF9; font-weight:700">@FormatTs(WeekNet)</MudText>
|
||||
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-primary-text); opacity: 0.6">Netto</MudText>
|
||||
<MudText Typo="Typo.h5" Color="Color.Secondary" Style="font-weight:700">@FormatTs(WeekNet)</MudText>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Gleitzeit</MudText>
|
||||
<MudText Typo="Typo.h5" Style="@($"color:{(WeekOvertime >= TimeSpan.Zero ? "#A5D6A7" : "#FFCC80")}; font-weight:700")">
|
||||
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-primary-text); opacity: 0.6">Gleitzeit</MudText>
|
||||
<MudText Typo="Typo.h5" Color="@(WeekOvertime >= TimeSpan.Zero ? Color.Success : Color.Warning)" Style="font-weight:700">
|
||||
@FormatTs(WeekOvertime, sign: true)
|
||||
</MudText>
|
||||
</MudStack>
|
||||
|
||||
@@ -1,149 +1,312 @@
|
||||
@page "/login"
|
||||
@rendermode InteractiveWebAssembly
|
||||
@attribute [AllowAnonymous]
|
||||
@using System.Net.Http.Json
|
||||
@inject IAuthService AuthService
|
||||
@inject NavigationManager Nav
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject HttpClient Http
|
||||
|
||||
<PageTitle>Anmelden – Timetracker</PageTitle>
|
||||
<PageTitle>@GetPageTitle()</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:#0EA5E9" />
|
||||
<MudText Typo="Typo.h4" Style="font-weight:700; color:#0EA5E9">Timetracker</MudText>
|
||||
</MudStack>
|
||||
|
||||
<MudPaper Elevation="4" Class="pa-6 rounded-xl" Style="width:100%">
|
||||
@* ── Tab Navigation ── *@
|
||||
<MudStack Row="true" Justify="Justify.Center" Class="mb-4">
|
||||
<MudButton OnClick="@(() => SetTab(0))"
|
||||
Variant="@(_activeTab == 0 ? Variant.Filled : Variant.Text)"
|
||||
Color="Color.Primary"
|
||||
Style="min-width: 120px; border-radius: 20px;">
|
||||
Anmelden
|
||||
</MudButton>
|
||||
<MudButton OnClick="@(() => SetTab(1))"
|
||||
Variant="@(_activeTab == 1 ? Variant.Filled : Variant.Text)"
|
||||
Color="Color.Primary"
|
||||
Style="min-width: 120px; border-radius: 20px;">
|
||||
Registrieren
|
||||
</MudButton>
|
||||
@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>
|
||||
|
||||
<MudDivider Class="mb-6" />
|
||||
<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>
|
||||
|
||||
@if (_activeTab == 0)
|
||||
{
|
||||
@* ── Login Form ── *@
|
||||
<MudStack Spacing="3">
|
||||
@if (_error != null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
|
||||
}
|
||||
<EditForm Model="@_loginModel" OnValidSubmit="HandleLogin">
|
||||
<MudStack Spacing="3">
|
||||
<MudTextField T="string"
|
||||
Label="Benutzername"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Person"
|
||||
@bind-Value="_loginModel.Username"
|
||||
Required="true"
|
||||
AutoFocus="true" />
|
||||
<MudTextField T="string"
|
||||
Label="Passwort"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Lock"
|
||||
InputType="InputType.Password"
|
||||
@bind-Value="_loginModel.Password"
|
||||
Required="true" />
|
||||
<MudButton ButtonType="ButtonType.Submit"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
FullWidth="true"
|
||||
Size="Size.Large"
|
||||
StartIcon="@Icons.Material.Filled.Login"
|
||||
Class="mt-2"
|
||||
Disabled="_loading">
|
||||
@if (_loading)
|
||||
{
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
|
||||
}
|
||||
Anmelden
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</EditForm>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ── Register Form ── *@
|
||||
<MudStack Spacing="3">
|
||||
@if (_error != null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
|
||||
}
|
||||
<EditForm Model="@_registerModel" OnValidSubmit="HandleRegister">
|
||||
<MudStack Spacing="3">
|
||||
<MudTextField T="string"
|
||||
Label="Benutzername"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Person"
|
||||
@bind-Value="_registerModel.Username"
|
||||
Required="true"
|
||||
HelperText="Mindestens 3 Zeichen" />
|
||||
<MudTextField T="string"
|
||||
Label="Passwort"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Lock"
|
||||
InputType="InputType.Password"
|
||||
@bind-Value="_registerModel.Password"
|
||||
Required="true"
|
||||
HelperText="Mindestens 6 Zeichen" />
|
||||
<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>
|
||||
<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">
|
||||
|
||||
<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>
|
||||
@* ── 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
|
||||
{
|
||||
@* ── 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;">
|
||||
@(_tenantInfo.IsTenant ? "Registrieren" : "Firma 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>
|
||||
</EditForm>
|
||||
</MudStack>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
}
|
||||
else
|
||||
{
|
||||
@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
|
||||
{
|
||||
@* ── 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>
|
||||
}
|
||||
}
|
||||
}
|
||||
</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 readonly AuthModel _loginModel = new();
|
||||
private readonly AuthModel _registerModel = new();
|
||||
private readonly FirmModel _firmModel = new();
|
||||
|
||||
[SupplyParameterFromQuery(Name = "error")]
|
||||
public string? ErrorParam { get; set; }
|
||||
@@ -151,6 +314,20 @@
|
||||
[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
|
||||
@@ -168,6 +345,35 @@
|
||||
_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;
|
||||
@@ -177,7 +383,7 @@
|
||||
var user = await AuthService.LoginAsync(_loginModel.Username, _loginModel.Password);
|
||||
if (user != null)
|
||||
{
|
||||
Nav.NavigateTo("/", forceLoad: true); // forceLoad forces state update/re-render of the root app
|
||||
Nav.NavigateTo("/", forceLoad: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -226,9 +432,62 @@
|
||||
}
|
||||
}
|
||||
|
||||
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; } = "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,29 +19,28 @@ else
|
||||
<MudStack Spacing="3">
|
||||
|
||||
@* ── Monats-Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
||||
Style="background: #1E293B; color: white;">
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
|
||||
Style="color:white" Size="Size.Large" OnClick="PrevMonth" />
|
||||
Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="PrevMonth" />
|
||||
<MudStack Spacing="0" AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.h5" Style="color:white; font-weight:700; letter-spacing:0.5px">
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700; letter-spacing:0.5px">
|
||||
@_deCulture.DateTimeFormat.GetMonthName(_month) @_year
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
|
||||
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">
|
||||
@_subLabel
|
||||
</MudText>
|
||||
</MudStack>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
|
||||
@if (!IsCurrentMonth)
|
||||
{
|
||||
<MudButton Variant="Variant.Text" Style="color:white"
|
||||
<MudButton Variant="Variant.Text" Style="color: var(--mud-palette-primary-text)"
|
||||
OnClick="GoToCurrentMonth" Size="Size.Small">
|
||||
Heute
|
||||
</MudButton>
|
||||
}
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
|
||||
Style="color:white" Size="Size.Large" OnClick="NextMonth" />
|
||||
Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="NextMonth" />
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
@@ -95,7 +94,7 @@ else
|
||||
|
||||
@* ── Monatszusammenfassung ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
||||
Style="background: linear-gradient(135deg, rgba(14,165,233,0.08) 0%, rgba(14,165,233,0.02) 100%); border-left: 6px solid #0EA5E9;">
|
||||
Style="background: linear-gradient(135deg, color-mix(in srgb, var(--mud-palette-primary) 8%, transparent) 0%, color-mix(in srgb, var(--mud-palette-primary) 2%, transparent) 100%); border-left: 6px solid var(--mud-palette-primary);">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-4">
|
||||
<MudIcon Icon="@Icons.Material.Filled.CalendarViewMonth" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.h6" Style="font-weight:700">Monatszusammenfassung</MudText>
|
||||
|
||||
@@ -22,13 +22,12 @@ else
|
||||
<MudStack Spacing="4">
|
||||
|
||||
@* ── Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
||||
Style="background: #1E293B; color:white;">
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AccountCircle" Style="color:white; font-size:2.5rem" />
|
||||
<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:white; font-weight:700">Mein Profil</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700">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>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
@namespace timetracker.Client.Components.Pages
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<MudText Class="mb-4">Geben Sie das neue Passwort für den Benutzer <strong>@Username</strong> ein.</MudText>
|
||||
<MudTextField @bind-Value="_newPassword"
|
||||
Label="Neues Passwort"
|
||||
InputType="InputType.Password"
|
||||
Variant="Variant.Outlined"
|
||||
AutoFocus="true"
|
||||
Required="true"
|
||||
HelperText="Mindestens 6 Zeichen" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel">Abbrechen</MudButton>
|
||||
<MudButton Color="Color.Warning" Variant="Variant.Filled" OnClick="Submit" Disabled="@(string.IsNullOrEmpty(_newPassword) || _newPassword.Length < 6)">Passwort zurücksetzen</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!;
|
||||
[Parameter] public string Username { get; set; } = "";
|
||||
|
||||
private string _newPassword = "";
|
||||
|
||||
private void Submit() => MudDialog.Close(DialogResult.Ok(_newPassword));
|
||||
private void Cancel() => MudDialog.Cancel();
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject NavigationManager Nav
|
||||
@inject HttpClient Http
|
||||
|
||||
|
||||
<PageTitle>Einstellungen – Timetracker</PageTitle>
|
||||
@@ -22,13 +23,12 @@ else
|
||||
<MudStack Spacing="4">
|
||||
|
||||
@* ── Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
||||
Style="background: #1E293B; color:white;">
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Settings" Style="color:white; font-size:2rem" />
|
||||
<MudIcon Icon="@Icons.Material.Filled.Settings" Style="color: var(--mud-palette-primary-text); font-size:2rem" />
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.h5" Style="color:white; font-weight:700">Einstellungen</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700">Einstellungen</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">
|
||||
Arbeitszeit, Arbeitstage und Urlaub konfigurieren
|
||||
</MudText>
|
||||
</MudStack>
|
||||
@@ -217,6 +217,52 @@ else
|
||||
</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 ── *@
|
||||
@@ -467,6 +513,11 @@ else
|
||||
|
||||
private AppSettings? _settings;
|
||||
private int _userId;
|
||||
private bool _isTenantAdmin;
|
||||
private TenantInfo? _tenantInfo;
|
||||
private string _tenantName = "";
|
||||
private string _tenantPrimaryColor = "#0EA5E9";
|
||||
private string _tenantSecondaryColor = "#0EA5E9";
|
||||
private DateTime? _flexStartDate
|
||||
{
|
||||
get => _settings?.FlexTimeStartDate?.ToDateTime(TimeOnly.MinValue);
|
||||
@@ -526,6 +577,25 @@ else
|
||||
_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);
|
||||
|
||||
@@ -622,6 +692,35 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -20,12 +20,12 @@ else
|
||||
<MudStack Spacing="4">
|
||||
|
||||
@* ── Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl" Style="background: #1E293B; color: white;">
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
|
||||
<MudIcon Icon="@Icons.Material.Filled.BarChart" Style="color:white; font-size:2.2rem" />
|
||||
<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:white; font-weight:700">Statistiken</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">Auswertung deiner Arbeitsleistung und Gleitzeitkonto</MudText>
|
||||
<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>
|
||||
@@ -46,7 +46,7 @@ else
|
||||
<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:#0F172A;">@displayPct%</MudText>
|
||||
<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>
|
||||
@@ -54,7 +54,7 @@ else
|
||||
<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:#0F172A">@FormatHours(_weekWorkedHours) Std.</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>
|
||||
@@ -68,7 +68,7 @@ else
|
||||
|
||||
@* ── Card 2: Wochensaldo ── *@
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudCard Elevation="3" Class="rounded-xl" Style="height:100%; border-left: 6px solid #0EA5E9;">
|
||||
<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>
|
||||
@@ -115,7 +115,7 @@ else
|
||||
<MudCard Elevation="3" Class="rounded-xl">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6" Style="font-weight:700; color:#0F172A">Arbeitszeitverteilung diese Woche</MudText>
|
||||
<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>
|
||||
@@ -148,8 +148,8 @@ else
|
||||
{
|
||||
double val = (_maxHoursValue / 4.0) * i;
|
||||
double yPos = plotBottom - (val / _maxHoursValue) * plotHeight;
|
||||
<line x1="@plotLeft" y1="@yPos" x2="@plotRight" y2="@yPos" stroke="#E2E8F0" stroke-width="1" stroke-dasharray="2" />
|
||||
@((MarkupString)$"<text x=\"{plotLeft - 8}\" y=\"{yPos + 4}\" fill=\"#64748B\" font-size=\"11\" font-weight=\"500\" text-anchor=\"end\">{val:F1}h</text>")
|
||||
<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 -->
|
||||
@@ -163,7 +163,7 @@ else
|
||||
string fillCol = "#94A3B8"; // Default light slate
|
||||
if (d.IsToday)
|
||||
{
|
||||
fillCol = "#0EA5E9"; // Sky Blue for today
|
||||
fillCol = "var(--mud-palette-secondary)"; // Brand color for today
|
||||
}
|
||||
else if (d.WorkedHours >= d.TargetHours && d.TargetHours > 0)
|
||||
{
|
||||
@@ -183,7 +183,7 @@ else
|
||||
}
|
||||
|
||||
<!-- Render Rect with hover tooltip -->
|
||||
<rect x="@xPos" y="@(d.WorkedHours > 0 ? yPos : plotBottom - 4)" width="@barWidth" height="@(d.WorkedHours > 0 ? bHeight : 4)"
|
||||
<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>
|
||||
@@ -191,19 +191,19 @@ else
|
||||
<!-- Value Label above bar -->
|
||||
@if (d.WorkedHours > 0)
|
||||
{
|
||||
@((MarkupString)$"<text x=\"{xPos + barWidth / 2}\" y=\"{yPos - 6}\" fill=\"#0F172A\" font-size=\"11\" font-weight=\"700\" text-anchor=\"middle\">{FormatHours(d.WorkedHours)}</text>")
|
||||
@((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=\"{xPos + barWidth / 2}\" y=\"{plotBottom + 20}\" fill=\"#64748B\" font-size=\"11\" font-weight=\"700\" text-anchor=\"middle\">{d.DayShortName}</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="@plotLeft" y1="@yTarget" x2="@plotRight" y2="@yTarget" stroke="#EF4444" stroke-width="2" stroke-dasharray="4" style="opacity: 0.75;" />
|
||||
@((MarkupString)$"<text x=\"{plotRight - 10}\" y=\"{yTarget - 6}\" fill=\"#EF4444\" font-size=\"10\" font-weight=\"700\" text-anchor=\"end\">Soll ({_settings.DailyTargetHours} h)</text>")
|
||||
<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>
|
||||
@@ -269,6 +269,7 @@ else
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
@page "/superadmin"
|
||||
@rendermode InteractiveWebAssembly
|
||||
@attribute [Authorize(Policy = "AdminOnly")]
|
||||
@using System.Net.Http.Json
|
||||
@inject HttpClient Http
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
|
||||
<PageTitle>Firmenverwaltung – Timetracker SaaS</PageTitle>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<MudStack AlignItems="AlignItems.Center" Class="mt-16" Spacing="3">
|
||||
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
|
||||
<MudText Color="Color.Secondary">Lade Firmen und Mandanten…</MudText>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Spacing="4">
|
||||
|
||||
@* ── Header Card ── *@
|
||||
<MudPaper Elevation="4" Class="pa-6 rounded-xl"
|
||||
Style="background: linear-gradient(135deg, var(--mud-palette-primary-darken) 0%, var(--mud-palette-primary) 100%); color: var(--mud-palette-primary-text);">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="4">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Business" Style="color: var(--mud-palette-secondary); font-size:3rem" />
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:800">SuperAdmin Dashboard</MudText>
|
||||
<MudText Typo="Typo.body2" Style="color: var(--mud-palette-primary-text); opacity: 0.72">
|
||||
@_tenants.Count registrierte Firmen im System
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@* ── Tenants Table ── *@
|
||||
<MudCard Elevation="3" Class="rounded-xl overflow-hidden">
|
||||
<MudCardContent Class="pa-0">
|
||||
<MudTable Items="_tenants" Hover="true" Striped="true" Elevation="0" SortLabel="Sortieren">
|
||||
<HeaderContent>
|
||||
<MudTh Style="width: 50px;" />
|
||||
<MudTh><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.Id)">ID</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.Name)">Name</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.Subdomain)">Subdomain</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.CreatedAt)">Erstellt am</MudTableSortLabel></MudTh>
|
||||
<MudTh Style="text-align:center"><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.UserCount)">Benutzer</MudTableSortLabel></MudTh>
|
||||
<MudTh Style="text-align:center"><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.WorkDaysCount)">Arbeitstage</MudTableSortLabel></MudTh>
|
||||
<MudTh Style="text-align:center">Status</MudTh>
|
||||
<MudTh Style="text-align:right">Aktionen</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>
|
||||
<MudIconButton Icon="@(context.ShowDetails ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)"
|
||||
Size="Size.Small"
|
||||
OnClick="@(() => context.ShowDetails = !context.ShowDetails)" />
|
||||
</MudTd>
|
||||
<MudTd DataLabel="ID">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@context.Id</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Name">
|
||||
<MudText Typo="Typo.body1" Style="font-weight:600">@context.Name</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Subdomain">
|
||||
<MudChip T="string" Color="Color.Primary" Variant="Variant.Outlined" Size="Size.Small">
|
||||
@context.Subdomain
|
||||
</MudChip>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Erstellt am">
|
||||
<MudText Typo="Typo.body2">@context.CreatedAt.ToLocalTime().ToString("dd.MM.yyyy HH:mm")</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Benutzer" Style="text-align:center">
|
||||
<MudBadge Content="@context.UserCount" Color="Color.Default" Overlap="false" Class="mx-2" />
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Arbeitstage" Style="text-align:center">
|
||||
<MudText Typo="Typo.body2">@context.WorkDaysCount</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Status" Style="text-align:center">
|
||||
@if (context.IsApproved)
|
||||
{
|
||||
<MudChip T="string" Color="Color.Success" Size="Size.Small" Icon="@Icons.Material.Filled.Check">Aktiv</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Color="Color.Warning" Size="Size.Small" Icon="@Icons.Material.Filled.HourglassEmpty">Ausstehend</MudChip>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd Style="text-align:right">
|
||||
@if (!context.IsApproved)
|
||||
{
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Success"
|
||||
Size="Size.Small"
|
||||
StartIcon="@Icons.Material.Filled.CheckCircle"
|
||||
OnClick="@(() => ApproveTenant(context))"
|
||||
Class="mr-1">
|
||||
Freigeben
|
||||
</MudButton>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Warning"
|
||||
Size="Size.Small"
|
||||
StartIcon="@Icons.Material.Filled.Block"
|
||||
OnClick="@(() => ToggleActiveTenant(context))"
|
||||
Class="mr-1">
|
||||
Sperren
|
||||
</MudButton>
|
||||
}
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Color="Color.Error"
|
||||
Size="Size.Small"
|
||||
OnClick="@(() => DeleteTenant(context))" />
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<ChildRowContent>
|
||||
@if (context.ShowDetails)
|
||||
{
|
||||
<MudTr>
|
||||
<td colspan="9" style="background-color: var(--mud-palette-background-grey); padding: 16px;">
|
||||
<MudPaper Elevation="1" Class="pa-4 rounded-lg">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-3" Style="font-weight: 700;">
|
||||
Benutzer für Firma: @context.Name
|
||||
</MudText>
|
||||
@if (context.Users == null || context.Users.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Keine Benutzer in dieser Firma registriert.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid Spacing="2">
|
||||
@foreach (var user in context.Users)
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudPaper Outlined="true" Class="pa-3 rounded-lg d-flex align-center justify-space-between" Style="background: white;">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudAvatar Color="user.IsTenantAdmin ? Color.Primary : Color.Default" Size="Size.Small">
|
||||
@user.Username.Substring(0, Math.Min(2, user.Username.Length)).ToUpper()
|
||||
</MudAvatar>
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.body2" Style="font-weight: 600;">@user.Username</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">ID: @user.Id</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
@if (user.IsTenantAdmin)
|
||||
{
|
||||
<MudChip T="string" Color="Color.Primary" Size="Size.Small" Variant="Variant.Text" Icon="@Icons.Material.Filled.AdminPanelSettings">Admin</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Color="Color.Default" Size="Size.Small" Variant="Variant.Text" Icon="@Icons.Material.Filled.Person">Mitarbeiter</MudChip>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
}
|
||||
</MudPaper>
|
||||
</td>
|
||||
</MudTr>
|
||||
}
|
||||
</ChildRowContent>
|
||||
</MudTable>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@code {
|
||||
private bool _loading = true;
|
||||
private List<TenantStats> _tenants = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadTenants();
|
||||
}
|
||||
|
||||
private async Task LoadTenants()
|
||||
{
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
_tenants = await Http.GetFromJsonAsync<List<TenantStats>>("api/superadmin/tenants") ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Fehler beim Laden der Firmen: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApproveTenant(TenantStats tenant)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await Http.PostAsync($"api/superadmin/tenants/{tenant.Id}/approve", null);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
tenant.IsApproved = true;
|
||||
Snackbar.Add($"Firma '{tenant.Name}' wurde erfolgreich freigegeben.", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("Fehler bei der Freigabe.", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Fehler: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ToggleActiveTenant(TenantStats tenant)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await Http.PostAsync($"api/superadmin/tenants/{tenant.Id}/toggle-active", null);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
tenant.IsApproved = !tenant.IsApproved;
|
||||
var stateMsg = tenant.IsApproved ? "aktiviert" : "gesperrt";
|
||||
Snackbar.Add($"Firma '{tenant.Name}' wurde {stateMsg}.", Severity.Info);
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("Fehler beim Ändern des Status.", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Fehler: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteTenant(TenantStats tenant)
|
||||
{
|
||||
bool? result = await DialogService.ShowMessageBoxAsync(
|
||||
"Firma unwiderruflich löschen?",
|
||||
$"Sind Sie sicher, dass Sie die Firma '{tenant.Name}' ({tenant.Subdomain}) inklusive ALLER zugehörigen Benutzer, Einstellungen und Zeiterfassungen unwiderruflich löschen möchten? Dieser Vorgang kann nicht rückgängig gemacht werden!",
|
||||
yesText: "Löschen", cancelText: "Abbrechen");
|
||||
|
||||
if (result == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await Http.DeleteAsync($"api/superadmin/tenants/{tenant.Id}");
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_tenants.Remove(tenant);
|
||||
Snackbar.Add($"Firma '{tenant.Name}' wurde gelöscht.", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
var err = await response.Content.ReadAsStringAsync();
|
||||
Snackbar.Add($"Fehler beim Löschen: {err}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Fehler: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,4 +81,38 @@ public class ClientAuthService : IAuthService
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,9 @@ public class HostAuthenticationStateProvider : AuthenticationStateProvider
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, userInfo.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, userInfo.Username)
|
||||
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);
|
||||
@@ -58,7 +60,9 @@ public class HostAuthenticationStateProvider : AuthenticationStateProvider
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, userInfo.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, userInfo.Username)
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user