34 Commits

Author SHA1 Message Date
mrcwlnd c27b1088ee Merge pull request 'sql updates' (#15) from testbranch into main
Reviewed-on: #15
2026-06-25 19:08:29 +00:00
MarcWieland 2305e4b728 sql updates 2026-06-25 21:08:13 +02:00
mrcwlnd 807d56d071 Merge pull request 'asynchroner tenant' (#14) from testbranch into main
Reviewed-on: #14
2026-06-25 19:04:19 +00:00
MarcWieland b6dc66744f asynchroner tenant 2026-06-25 21:04:04 +02:00
mrcwlnd c77cd7d524 Merge pull request 'fixed' (#13) from testbranch into main
Reviewed-on: #13
2026-06-25 18:59:26 +00:00
MarcWieland a01f80bb14 fixed 2026-06-25 20:59:10 +02:00
mrcwlnd 17c8e5e9ed Merge pull request 'feat: ignore pending model changes warnings and update EF Core version in DbContext configuration' (#12) from testbranch into main
Reviewed-on: #12
2026-06-25 18:49:21 +00:00
MarcWieland 9980d9217a feat: ignore pending model changes warnings and update EF Core version in DbContext configuration 2026-06-25 20:49:00 +02:00
mrcwlnd 9bcce06842 Merge pull request 'fixed database' (#11) from testbranch into main
Reviewed-on: #11
2026-06-25 18:41:18 +00:00
MarcWieland 03b025c09c fixed database 2026-06-25 20:40:28 +02:00
mrcwlnd 528eeb13a1 Merge pull request 'Multi tenant integration' (#10) from multi-tenant-integration into main
Reviewed-on: #10
2026-06-25 18:34:05 +00:00
mrcwlnd d5f68ca520 Merge branch 'main' into multi-tenant-integration 2026-06-25 18:34:01 +00:00
MarcWieland 0719d41fe1 changelog angepasst 2026-06-25 20:23:39 +02:00
MarcWieland b12e94128c Excel Export eingebaut 2026-06-25 20:22:35 +02:00
MarcWieland 2f95fa0e3e auch einzelnutzer können sich registrieren 2026-06-25 20:15:07 +02:00
MarcWieland ac06059e2a multi-tenant implementierung 2026-06-24 23:48:04 +02:00
mrcwlnd 8a0bf59994 Merge pull request 'feat: add vacation day management to home view and persist changelog version in local storage' (#9) from multi-tenant-integration into main
Reviewed-on: #9
2026-06-24 20:39:48 +00:00
MarcWieland d646cfd28d feat: add vacation day management to home view and persist changelog version in local storage 2026-06-24 22:39:22 +02:00
mrcwlnd 147b589d59 Merge pull request 'little bugfix' (#8) from multi-tenant-integration into main
Reviewed-on: #8
2026-06-24 20:33:35 +00:00
MarcWieland 24cbbbf624 little bugfix 2026-06-24 22:33:23 +02:00
mrcwlnd a379088a24 Merge pull request 'Huge updates' (#7) from navbar_styling into main
Reviewed-on: #7
2026-06-24 20:29:47 +00:00
MarcWieland 0d59d521dc Huge updates 2026-06-24 22:29:05 +02:00
mrcwlnd 9824616323 Merge pull request 'Neue agb seite implementiert' (#6) from navbar_styling into main
Reviewed-on: #6
2026-06-08 22:24:11 +00:00
MarcWieland 94c10aebdd Onboarding 2026-06-09 00:22:30 +02:00
MarcWieland ddb2b4af9f Neue agb seite implementiert 2026-06-09 00:15:06 +02:00
mrcwlnd 8bd7423209 Merge pull request 'Neue navbar' (#5) from navbar_styling into main
Reviewed-on: #5
2026-06-08 22:06:32 +00:00
MarcWieland 1def618e34 Neue navbar 2026-06-09 00:06:18 +02:00
MarcWieland 2029524379 Design updates 2026-06-08 23:52:22 +02:00
MarcWieland f92dd2659c Increased version 2026-06-08 23:36:04 +02:00
MarcWieland 82626bc5b3 Timebot implementation 2026-06-08 23:34:08 +02:00
mrcwlnd b8b01871ed Merge pull request 'Dynamischer page title' (#4) from wasm-integration into main
Reviewed-on: #4
2026-06-08 14:43:02 +00:00
mrcwlnd 69a38761f3 Merge pull request 'peformance verbesserungen' (#3) from wasm-integration into main
Reviewed-on: #3
2026-06-08 14:39:44 +00:00
mrcwlnd 3f262910ad Merge pull request 'fixed dockerfile' (#2) from wasm-integration into main
Reviewed-on: #2
2026-06-08 14:32:13 +00:00
mrcwlnd ed36d0e1ec Merge pull request 'WASM Mode activated' (#1) from wasm-integration into main
Reviewed-on: #1
2026-06-08 14:25:30 +00:00
59 changed files with 6205 additions and 646 deletions
Vendored
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 MiB

+73
View File
@@ -0,0 +1,73 @@
# Brainstorming & Zukunfts-Ideen für den Timetracker
Der Timetracker ist durch die Umstellung auf eine Hosted Blazor WebAssembly (.NET 10) Architektur mit PostgreSQL-Unterstützung extrem solide und zukunftssicher aufgestellt. Hier sind einige spannende, innovative Produktideen, um das Tool auf das nächste Level zu heben:
---
## 🚀 1. PWA-Support (Progressive Web App) und Offline-Modus
* **Offline-Erfassung**: Da die App in Blazor WebAssembly im Browser läuft, lässt sie sich leicht zu einer PWA umrüsten. Zeiterfassungen könnten lokal im `IndexedDB`-Speicher des Browsers gesichert werden, wenn keine Internetverbindung besteht, und bei Wiederverbindung automatisch mit der PostgreSQL-Datenbank synchronisiert werden.
* **Mobiles App-Feeling**: Installation direkt auf dem Homescreen (Android/iOS) ohne App-Store-Zwang.
* **Web-Push Notifications**: Tägliche Erinnerungen, falls man vergisst, sich auszustempeln oder Pausen einzutragen.
---
## 🤖 2. KI-Assistent & Smart-Suggestions
* **Kalender- & Git-Sync**: Einbindung einer KI-Schnittstelle, die Zugriff auf Terminkalender (Google/Outlook) oder lokale Git-Commits hat. Sie schlägt am Ende des Tages automatisch passende Buchungen vor (z. B. *"Du hast von 10-11 Uhr am Feature X gearbeitet. Möchtest du das buchen?"*).
* **Ausreißer-Erkennung**: KI-gestützte Warnungen, wenn Zeiten untypisch erfasst wurden (z. B. 12 Stunden gearbeitet ohne Pause).
---
## 📅 3. Kalender-Integration & Feiertags-Automatisierung
* **Direktes Feiertags-Abonnement**: Automatische Erkennung des Bundeslandes und Import der Feiertage ohne manuellen Klick auf "Von API laden" zu Beginn des Jahres.
* **Kalender-Export (iCal)**: Generierung eines persönlichen iCal-Feeds, damit der User seine gebuchten Urlaubstage und Überstunden in seinem primären Kalender (Google Calendar, Apple Calendar) sieht.
---
## 👥 4. Team-Features & Urlaubsplaner
* **Wer-ist-da-Dashboard**: Ein Live-Statusboard für Teams, auf dem man sieht, wer gerade eingestempelt ist, wer Pause macht oder wer heute Urlaub hat/krankgeschrieben ist.
* **Gemeinsamer Urlaubskalender**: Ein Kalender für das gesamte Team, um Urlaubsüberschneidungen direkt zu erkennen und Freigabeprozesse durch Admins zu ermöglichen.
* **Rollenkonzepte**: Einführung von Teamleitern (können Zeiten der Mitglieder freigeben) und Standard-Mitarbeitern.
---
## 📊 5. Reporting & Abrechnung (Billing)
* **Rechnungserstellung**: Verknüpfung von erfassten Zeiten mit Stundensätzen, um mit einem Klick professionelle PDF-Rechnungen an Kunden zu erstellen.
* **Auswertungen**: Interaktive Diagramme (z.B. mittels MudBlazor Charts) zur Analyse von Arbeitszeiten, Überstundenentwicklung und Krankheitstagen über das Jahr verteilt.
---
## 🔌 6. Integrationen (Jira, GitHub, Slack)
* **Git-Hooks / CLI**: Automatisches Starten/Stoppen von Timern per CLI oder bei Git-Commits.
* **Slack / Teams Chatbot**: Schnelles Ein- und Ausstempeln direkt aus dem Chat-Client heraus über Befehle wie `/track start` oder `/track pause`.
---
## 🔒 7. Sicherheit & DSGVO
* **Audit Logs**: Transparente Historie darüber, wer wann welche Zeiten geändert hat (besonders wichtig bei Prüfungen).
* **Automatisches Löschkonzept**: Möglichkeit zur Einhaltung von Löschfristen erfasster Daten nach Beendigung des Arbeitsverhältnisses.
---
## ✉️ 8. E-Mail-Verifizierung & Inhouse-Mailserver
* **Sicherer Registrierungs-Flow**: E-Mail-Verifizierung bei Neuregistrierung mittels eines 6-stelligen, zeitlich begrenzten Bestätigungscodes. Erst nach Eingabe des Codes wird das Benutzerkonto aktiviert und der Login erlaubt.
* **Inhouse SMTP-Lösungen**:
* **Lokale Entwicklung**: Einbindung eines SMTP-Sandboxes wie **Mailpit** (oder MailHog) direkt in die `docker-compose.yml`, um E-Mails sicher lokal zu fangen und im Browser zu sichten.
* **Produktion**: Konfiguration für inhouse SMTP-Relays oder Aufbau eines vollverschlüsselten **docker-mailserver** Containers für den komplett eigenständigen, datenschutzkonformen E-Mail-Versand.
---
## 🏢 9. B2B-Fähigkeit & Azure AD (Entra ID) Integration
* **Generisches Single Sign-On (SSO)**: Integration von OpenID Connect (OIDC) über Umgebungsvariablen. Dadurch können Unternehmen das Tool nahtlos an Microsoft Entra ID (Azure AD), Okta, Keycloak oder Google Workspace anbinden.
* **White-Labeling & Customizing (Single-Tenant)**: Möglichkeit, Firmenlogos, Firmennamen und Standardeinstellungen (z.B. Feiertags-Bundesland) pro Docker-Compose-Instanz flexibel anzupassen.
* **Optionale SaaS-Erweiterung (Multi-Tenant)**: Zukünftiger Ausbau für mehrmandantenfähigen Betrieb mit Datenisolation auf Tabellenebene (`TenantId`), dynamischer SSO-Zuordnung per E-Mail-Domain und zentraler Rechnungsabwicklung (z.B. Stripe).
---
## 💬 10. Gamifizierter Assistent & KI-Chatbot
* **Regelbasierter Gamification-Widget (Quick-Win)**: Ein kleiner, charismatischer Assistent/Avatar im UI, der basierend auf den aktuellen Arbeitsstatistiken motivierende, freche oder informative Sprüche anzeigt (z.B. bei vielen Überstunden, fehlenden Pausen oder anstehendem Urlaub).
* **LLM-gestützter KI-Assistent (Smart-Assistant)**:
* **Analysen**: Ein Chat-Fenster, in dem der Benutzer Fragen zu seinen Arbeitszeiten stellen kann (z.B. *"Wie viele Überstunden habe ich diesen Monat angesammelt?"* oder *"Wann habe ich letzte Woche Dienstag angefangen?"*).
* **Interaktion & Aktionen**: Die KI kann durch Function Calling direkt Aktionen ausführen (z.B. *"Trage mir für morgen 8 Stunden Arbeit ein"* oder *"Trage Urlaub für KW 28 ein"*).
* **Technologie**: Anbindung über ein einfaches HttpClient-Modul an die Google Gemini API, OpenAI oder lokal über ein Ollama-Docker-Container.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

@@ -0,0 +1,297 @@
@inject ITimetrackerService TrackerService
@inject AuthenticationStateProvider AuthStateProvider
@inject IJSRuntime JS
@using System.Security.Claims
@using timetracker.Shared
@if (_isOpen)
{
<div class="timebot-chat-window">
<!-- Chat Header -->
<div class="timebot-header">
<div class="timebot-header-info">
<img src="/timebot.png" class="timebot-header-avatar" alt="Timebot" />
<div class="timebot-header-text">
<span class="timebot-header-title">Timebot</span>
<span class="timebot-header-status"><span class="status-dot"></span> Online</span>
</div>
</div>
<button class="timebot-close-btn" @onclick="ToggleChat" aria-label="Schließen">
<MudIcon Icon="@Icons.Material.Filled.Close" Size="Size.Small" Style="color: white;" />
</button>
</div>
<!-- Chat Body -->
<div class="timebot-body" id="timebot-body-el">
@foreach (var msg in _messages)
{
<div class="timebot-msg-row @(msg.Sender == "User" ? "user-row" : "bot-row")">
@if (msg.Sender == "Bot")
{
<img src="/timebot.png" class="timebot-msg-avatar" alt="Timebot" />
}
<div class="timebot-msg-bubble @(msg.Sender == "User" ? "user-bubble" : "bot-bubble")">
@if (msg.IsTyping)
{
<div class="typing-indicator">
<span></span><span></span><span></span>
</div>
}
else
{
@((MarkupString)FormatMessageText(msg.Content))
}
</div>
</div>
}
</div>
<!-- Suggestion Chips -->
<div class="timebot-suggestions">
<button @onclick="@(() => AskQuestion("Überstunden"))">📊 Überstunden</button>
<button @onclick="@(() => AskQuestion("Woche"))">⏱️ Wochenarbeitszeit</button>
<button @onclick="@(() => AskQuestion("Urlaub"))">🏖️ Urlaubs-Maximizer</button>
<button @onclick="@(() => AskQuestion("Hilfe"))">💡 Hilfe</button>
</div>
<!-- Chat Footer -->
<div class="timebot-footer">
<input type="text" @bind="_userInput" @bind:event="oninput" @onkeypress="HandleKeyPress" placeholder="Schreibe eine Nachricht..." class="timebot-input" />
<button class="timebot-send-btn" @onclick="SendMessage" disabled="@(string.IsNullOrWhiteSpace(_userInput))">
<MudIcon Icon="@Icons.Material.Filled.Send" Style="color: #3F51B5;" />
</button>
</div>
</div>
}
<!-- Floating Button -->
<div class="timebot-launcher @(_isOpen ? "open" : "")" @onclick="ToggleChat">
<img src="/timebot.png" alt="Timebot Launcher" />
@if (_unreadCount > 0 && !_isOpen)
{
<span class="timebot-badge">@_unreadCount</span>
}
</div>
@code {
private bool _isOpen = false;
private string _userInput = "";
private int _unreadCount = 1;
private List<ChatMessage> _messages = new();
private int _userId;
private string _username = "Marc";
private AppSettings _settings = new();
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim != null)
{
_userId = int.Parse(claim.Value);
_username = authState.User.Identity?.Name ?? "Marc";
if (!string.IsNullOrEmpty(_username))
{
_username = char.ToUpper(_username[0]) + _username.Substring(1);
}
_settings = await TrackerService.GetSettingsAsync(_userId);
}
_messages.Add(new ChatMessage
{
Sender = "Bot",
Content = $"Hallo {_username}! 👋 Ich bin dein Timebot. Ich habe ein Auge auf deine Zeiten geworfen.\n\nWie kann ich dir heute helfen?",
Timestamp = DateTime.Now
});
}
private void ToggleChat()
{
_isOpen = !_isOpen;
if (_isOpen)
{
_unreadCount = 0;
_ = ScrollToBottom();
}
}
private async Task HandleKeyPress(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_userInput))
{
await SendMessage();
}
}
private async Task SendMessage()
{
if (string.IsNullOrWhiteSpace(_userInput)) return;
var text = _userInput;
_userInput = "";
_messages.Add(new ChatMessage { Sender = "User", Content = text, Timestamp = DateTime.Now });
await ScrollToBottom();
await RespondTo(text);
}
private async Task AskQuestion(string key)
{
string userQuestion = "";
if (key == "Überstunden") userQuestion = "Wie viele Überstunden habe ich?";
else if (key == "Woche") userQuestion = "Wie sieht meine Woche aus?";
else if (key == "Urlaub") userQuestion = "Wie hilft mir der Urlaubs-Maximizer?";
else if (key == "Hilfe") userQuestion = "Welche Fragen kann ich dir stellen?";
_messages.Add(new ChatMessage { Sender = "User", Content = userQuestion, Timestamp = DateTime.Now });
await ScrollToBottom();
await RespondTo(key);
}
private async Task RespondTo(string query)
{
// Add typing indicator
var typingMsg = new ChatMessage { Sender = "Bot", IsTyping = true, Timestamp = DateTime.Now };
_messages.Add(typingMsg);
await ScrollToBottom();
// Simulate thinking/typing delay
await Task.Delay(1000);
_messages.Remove(typingMsg);
string responseText = "";
var normalized = query.ToLower();
if (normalized.Contains("überstunden") || normalized == "überstunden" || normalized.Contains("saldo") || normalized.Contains("gleitzeit"))
{
var settings = await TrackerService.GetSettingsAsync(_userId);
var overtime = await TrackerService.GetTotalOvertimeAsync(_userId, settings);
var overtimeStr = FormatTs(overtime, sign: true);
if (overtime >= TimeSpan.Zero)
{
responseText = $"Dein Gleitzeitkonto sieht super aus! Du hast aktuell **{overtimeStr} Std.** auf deinem Gleitzeitkonto angesammelt.\n\nSehr stark! Hast du mit der Extrazeit schon etwas vor oder bist du einfach nur voll am Hustlen? 🚀🔥";
}
else
{
responseText = $"Dein Gleitzeitkonto steht aktuell bei **{overtimeStr} Std.**\n\nDu bist also ein kleines bisschen im Minus. Aber kein Grund zur Sorge: Das lässt sich mit ein paar Extra-Minuten in den nächsten Tagen leicht wieder ausgleichen! 💪";
}
}
else if (normalized.Contains("woche") || normalized == "woche" || normalized.Contains("arbeitszeit") || normalized.Contains("stunden"))
{
var today = DateOnly.FromDateTime(DateTime.Today);
var monday = GetMonday(today);
var settings = await TrackerService.GetSettingsAsync(_userId);
var weekDays = await TrackerService.GetWeekAsync(_userId, monday);
double workedHours = 0;
double targetHours = 0;
foreach (var date in Enumerable.Range(0, 7).Select(i => monday.AddDays(i)))
{
bool isWorkDay = settings.IsWorkDay(date.DayOfWeek);
if (isWorkDay)
{
targetHours += settings.DailyTargetHours;
}
var wd = weekDays.FirstOrDefault(d => d.Date == date);
if (wd != null && wd.StartTime != null && wd.EndTime != null)
{
var gross = wd.EndTime.Value.ToTimeSpan() - wd.StartTime.Value.ToTimeSpan();
if (gross > TimeSpan.Zero)
{
var breakTotal = wd.Breaks
.Where(b => b.StartTime.HasValue && b.EndTime.HasValue && b.EndTime > b.StartTime)
.Aggregate(TimeSpan.Zero, (s, b) =>
s + (b.EndTime!.Value.ToTimeSpan() - b.StartTime!.Value.ToTimeSpan()));
workedHours += (gross - breakTotal).TotalHours;
}
}
}
var workedTs = TimeSpan.FromHours(workedHours);
var targetTs = TimeSpan.FromHours(targetHours);
var percent = targetHours > 0 ? (int)Math.Round(workedHours / targetHours * 100) : 0;
responseText = $"Diese Woche hast du bereits **{FormatTs(workedTs)}** von geplanten **{FormatTs(targetTs)}** gearbeitet.\n\nDu hast diese Woche also schon **{percent}%** deines Solls erfüllt. Weiter so! ⏰";
}
else if (normalized.Contains("urlaub") || normalized == "urlaub" || normalized.Contains("maximizer") || normalized.Contains("brückentag"))
{
responseText = $"Der **Urlaubs-Maximizer** analysiert das Kalenderjahr, gesetzliche Feiertage und Brückentage, um dir die besten Zeiträume für Urlaub vorzuschlagen.\n\nDu findest die Auswertung direkt in der Sidebar unter **'Urlaubs-Maximizer'**. So machst du aus wenigen Urlaubstagen maximale Freizeit! 🏖️✈️";
}
else if (normalized.Contains("hilfe") || normalized == "hilfe" || normalized.Contains("hallo") || normalized.Contains("hi") || normalized.Contains("helo") || normalized.Contains("huhu") || normalized == "hilfe")
{
responseText = $"Ich kann dir Auskunft über deine Zeiten geben. Frage mich zum Beispiel:\n\n" +
$"• *'Wie viele Überstunden habe ich?'*\n" +
$"• *'Wie sieht meine Woche aus?'*\n" +
$"• *'Was macht der Urlaubs-Maximizer?'*\n\n" +
$"Oder tippe einfach deine Frage ein! 💡";
}
else
{
responseText = $"Entschuldige, diese Frage kann ich leider noch nicht beantworten. 🤖\n\nFrage mich gerne nach deinem **Überstunden-Saldo**, deiner **Wochen-Arbeitszeit** oder dem **Urlaubs-Maximizer**!";
}
_messages.Add(new ChatMessage { Sender = "Bot", Content = responseText, Timestamp = DateTime.Now });
await ScrollToBottom();
}
private async Task ScrollToBottom()
{
try
{
await JS.InvokeVoidAsync("eval", "setTimeout(() => { const el = document.getElementById('timebot-body-el'); if(el) el.scrollTop = el.scrollHeight; }, 50);");
}
catch
{
// Prerender-Pass ignorieren
}
}
private string FormatMessageText(string text)
{
// Simple helper to replace markdown bold (**text**) with HTML bold (<strong>text</strong>)
// and newlines (\n) with <br />
if (string.IsNullOrEmpty(text)) return "";
var formatted = text;
// Escape HTML first to prevent XSS
formatted = System.Net.WebUtility.HtmlEncode(formatted);
// Replace back escaped ** formatting
// Simple regex replace for **text**
var regex = new System.Text.RegularExpressions.Regex(@"\*\*(.*?)\*\*");
formatted = regex.Replace(formatted, "<strong>$1</strong>");
// Replace newlines
formatted = formatted.Replace("\n", "<br />");
return formatted;
}
private static DateOnly GetMonday(DateOnly date)
{
int diff = ((int)date.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7;
return date.AddDays(-diff);
}
private static string FormatTs(TimeSpan ts, bool sign = false)
{
if (ts == TimeSpan.Zero && sign) return "±0:00";
var prefix = sign ? (ts >= TimeSpan.Zero ? "+" : "") : (ts < TimeSpan.Zero ? "" : "");
var abs = ts.Duration();
return $"{prefix}{(int)abs.TotalHours}:{abs.Minutes:D2}";
}
private sealed class ChatMessage
{
public string Sender { get; set; } = "Bot";
public string Content { get; set; } = "";
public DateTime Timestamp { get; set; } = DateTime.Now;
public bool IsTyping { get; set; } = false;
}
}
@@ -0,0 +1,334 @@
/* Floating Launcher Button */
.timebot-launcher {
position: fixed;
bottom: 24px;
right: 24px;
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, #ffffff 0%, #f1f3f9 100%);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
cursor: pointer;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
border: 2px solid #0EA5E9;
}
.timebot-launcher:hover {
transform: scale(1.1) translateY(-4px);
box-shadow: 0 12px 28px rgba(14, 165, 233, 0.4);
border-color: #0284C7;
}
.timebot-launcher img {
width: 48px;
height: 48px;
object-fit: contain;
}
.timebot-launcher.open {
transform: scale(0.9) rotate(90deg);
opacity: 0.8;
}
.timebot-badge {
position: absolute;
top: -4px;
right: -4px;
background-color: #ff3b30;
color: white;
font-size: 11px;
font-weight: bold;
padding: 3px 7px;
border-radius: 10px;
border: 2px solid white;
}
/* Chat Window */
.timebot-chat-window {
position: fixed;
bottom: 100px;
right: 24px;
width: 360px;
height: 520px;
max-height: 80vh;
border-radius: 20px;
background: rgba(32, 33, 36, 0.95);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 9999;
animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
color: #f1f3f9;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Header */
.timebot-header {
background: #0F172A;
padding: 14px 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.timebot-header-info {
display: flex;
align-items: center;
gap: 12px;
}
.timebot-header-avatar {
width: 36px;
height: 36px;
background: rgba(255, 255, 255, 0.15);
border-radius: 50%;
padding: 2px;
object-fit: contain;
}
.timebot-header-text {
display: flex;
flex-direction: column;
}
.timebot-header-title {
font-weight: 700;
font-size: 15px;
color: white;
letter-spacing: 0.3px;
}
.timebot-header-status {
font-size: 11px;
color: rgba(255, 255, 255, 0.75);
display: flex;
align-items: center;
gap: 4px;
}
.status-dot {
width: 6px;
height: 6px;
background-color: #4cd964;
border-radius: 50%;
display: inline-block;
box-shadow: 0 0 8px #4cd964;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(76, 217, 100, 0.7); }
70% { transform: scale(1); box-shadow: 0 0 0 6px rgba(76, 217, 100, 0); }
100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(76, 217, 100, 0); }
}
.timebot-close-btn {
background: transparent;
border: none;
cursor: pointer;
opacity: 0.8;
transition: opacity 0.2s;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.timebot-close-btn:hover {
opacity: 1;
}
/* Chat Body */
.timebot-body {
flex: 1;
padding: 16px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
background: rgba(24, 25, 28, 0.4);
}
.timebot-msg-row {
display: flex;
gap: 8px;
max-width: 85%;
}
.timebot-msg-row.bot-row {
align-self: flex-start;
}
.timebot-msg-row.user-row {
align-self: flex-end;
flex-direction: row-reverse;
}
.timebot-msg-avatar {
width: 28px;
height: 28px;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
padding: 2px;
align-self: flex-end;
object-fit: contain;
}
.timebot-msg-bubble {
padding: 10px 14px;
border-radius: 16px;
font-size: 13.5px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
.bot-bubble {
background: rgba(255, 255, 255, 0.08);
color: #e3e3e7;
border-bottom-left-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.04);
}
.user-bubble {
background: linear-gradient(135deg, #0EA5E9 0%, #0284C7 100%);
color: white;
border-bottom-right-radius: 4px;
box-shadow: 0 4px 12px rgba(14, 165, 233, 0.2);
}
/* Typing Indicator */
.typing-indicator {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 0;
}
.typing-indicator span {
width: 6px;
height: 6px;
background-color: #a8b2c1;
border-radius: 50%;
display: inline-block;
animation: bounce 1.4s infinite both;
}
.typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
.typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1.0); }
}
/* Suggestions */
.timebot-suggestions {
display: flex;
gap: 6px;
padding: 8px 12px;
background: rgba(18, 18, 20, 0.6);
border-top: 1px solid rgba(255, 255, 255, 0.06);
overflow-x: auto;
scrollbar-width: none; /* Scrollbar hidden for visual aesthetics */
}
.timebot-suggestions::-webkit-scrollbar {
display: none;
}
.timebot-suggestions button {
flex-shrink: 0;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
color: #cbd5e1;
padding: 6px 12px;
border-radius: 14px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
}
.timebot-suggestions button:hover {
background: rgba(14, 165, 233, 0.15);
border-color: #0EA5E9;
color: white;
transform: translateY(-1px);
}
/* Footer */
.timebot-footer {
padding: 12px;
display: flex;
align-items: center;
gap: 8px;
background: rgba(32, 33, 36, 0.98);
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.timebot-input {
flex: 1;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
color: white;
padding: 10px 14px;
border-radius: 12px;
font-size: 13px;
outline: none;
transition: all 0.2s;
}
.timebot-input:focus {
border-color: #0EA5E9;
background: rgba(255, 255, 255, 0.1);
box-shadow: 0 0 0 2px rgba(14, 165, 233, 0.2);
}
.timebot-send-btn {
background: white;
border: none;
border-radius: 10px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.timebot-send-btn:hover {
background: #e0f2fe;
transform: scale(1.05);
}
.timebot-send-btn:disabled {
background: rgba(255, 255, 255, 0.04);
cursor: not-allowed;
transform: none;
}
.timebot-send-btn:disabled ::deep .mud-icon-root {
color: rgba(255, 255, 255, 0.2) !important;
}
@@ -2,41 +2,149 @@
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Nav
@inject IUserNotificationService UserNotificationService
@inject IJSRuntime JSRuntime
@inject HttpClient Http
@implements IDisposable
@using System.Security.Claims
<MudThemeProvider Theme="_theme" />
<MudThemeProvider Theme="_theme" @bind-IsDarkMode="_isDarkMode" />
<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>
<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>
<MudDrawer @bind-Open="_drawerOpen" ClipMode="DrawerClipMode.Always" Elevation="2">
<NavMenu />
<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 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)
{
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)
@@ -58,17 +166,38 @@
{
PaletteLight = new PaletteLight
{
Primary = "#3F51B5",
PrimaryDarken = "#1A237E",
PrimaryLighten = "#7986CB",
Secondary = "#009688",
SecondaryDarken = "#00695C",
AppbarBackground = "#3F51B5",
Background = "#F4F6F9",
DrawerBackground = "#FFFFFF",
Primary = "#1E293B",
PrimaryDarken = "#0F172A",
PrimaryLighten = "#475569",
Secondary = "#0EA5E9",
SecondaryDarken = "#0284C7",
AppbarBackground = "#0F172A",
Background = "#F8FAFC",
DrawerBackground = "#0F172A",
DrawerText = "#94A3B8",
DrawerIcon = "#94A3B8",
Surface = "#FFFFFF",
TextPrimary = "#212121",
TextSecondary = "#757575",
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)",
}
};
@@ -1,81 +1,40 @@
.page {
position: relative;
display: flex;
flex-direction: column;
/* 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);
}
main {
flex: 1;
::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;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
/* 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;
}
.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;
}
/* 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;
@@ -90,9 +49,9 @@ main {
z-index: 1000;
}
#blazor-error-ui .dismiss {
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
}
@@ -1,36 +1,170 @@
<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" />
@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">
<MudNavLink Href="admin/users" Icon="@Icons.Material.Filled.AdminPanelSettings" IconColor="Color.Error">
<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>
</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>
</MudTooltip>
</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.3
</MudNavLink>
</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,105 +1,357 @@
.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);
/* Base container covering full height */
.nav-container {
display: flex;
flex-direction: column;
height: 100%;
color: #94A3B8; /* Slate 400 */
}
.navbar-toggler:checked {
background-color: rgba(255, 255, 255, 0.5);
/* Ensure tooltips wrapper spans full width of menu */
::deep .mud-tooltip-root {
display: block !important;
width: 100% !important;
}
.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;
/* Header with logo and title */
.nav-header {
height: 64px;
display: flex;
align-items: center;
line-height: 3rem;
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%;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
transition: all 0.25s ease;
}
.nav-item ::deep .nav-link:hover {
background-color: rgba(255,255,255,0.1);
color: white;
::deep .drawer-toggle-btn {
color: #94A3B8 !important;
padding: 8px !important;
margin-left: -8px !important;
transition: all 0.2s ease !important;
}
.nav-scrollable {
display: none;
::deep .drawer-toggle-btn:hover {
color: #FFFFFF !important;
background-color: rgba(255, 255, 255, 0.05) !important;
}
.navbar-toggler:checked ~ .nav-scrollable {
display: block;
::deep .logo-icon {
color: var(--mud-palette-secondary) !important;
font-size: 1.6rem !important;
transition: opacity 0.2s ease;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.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;
}
.nav-scrollable {
/* Never collapse the sidebar for wide screens */
display: block;
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
/* Navigation scrollable content */
.nav-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
/* Divider inside navigation list */
.admin-divider-container {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 20px 8px 20px;
box-sizing: border-box;
transition: padding 0.25s ease;
}
.admin-divider-text {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1.5px;
color: #475569; /* Slate 600 */
white-space: nowrap;
}
.admin-divider-line {
flex: 1;
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.06);
margin: 0;
}
/* Standard NavLink customization */
::deep .custom-nav-link {
margin: 4px 12px !important;
border-radius: 12px !important;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important;
overflow: hidden;
}
/* Normal (inactive) text and icon colors */
::deep .custom-nav-link,
::deep .custom-nav-link .mud-nav-link-text,
::deep .custom-nav-link .mud-nav-link-icon-default {
color: var(--mud-palette-drawer-text) !important;
}
/* NavLink hover */
::deep .custom-nav-link:hover {
background-color: rgba(255, 255, 255, 0.05) !important;
}
::deep .custom-nav-link:hover,
::deep .custom-nav-link:hover .mud-nav-link-text,
::deep .custom-nav-link:hover .mud-nav-link-icon-default {
color: var(--mud-palette-appbar-text) !important;
}
/* NavLink active state (Fixes active color contrast issue) */
::deep .custom-nav-link.active,
::deep .custom-nav-link .active {
background: linear-gradient(90deg, color-mix(in srgb, var(--mud-palette-secondary) 15%, transparent) 0%, color-mix(in srgb, var(--mud-palette-secondary) 4%, transparent) 100%) !important;
font-weight: 600 !important;
box-shadow: inset 3px 0 0 var(--mud-palette-secondary) !important;
}
::deep .custom-nav-link.active,
::deep .custom-nav-link .active,
::deep .custom-nav-link.active .mud-nav-link-text,
::deep .custom-nav-link .active .mud-nav-link-text,
::deep .custom-nav-link.active .mud-nav-link-icon-default,
::deep .custom-nav-link .active .mud-nav-link-icon-default {
color: var(--mud-palette-secondary) !important;
}
/* Admin link color overrides & active state */
::deep .admin-link .mud-nav-link-icon-default {
color: rgba(239, 68, 68, 0.7) !important; /* Red 500 */
}
::deep .admin-link.active,
::deep .admin-link .active {
background: linear-gradient(90deg, rgba(239, 68, 68, 0.15) 0%, rgba(239, 68, 68, 0.04) 100%) !important;
box-shadow: inset 3px 0 0 #EF4444 !important;
}
::deep .admin-link.active,
::deep .admin-link .active,
::deep .admin-link.active .mud-nav-link-text,
::deep .admin-link .active .mud-nav-link-text,
::deep .admin-link.active .mud-nav-link-icon-default,
::deep .admin-link .active .mud-nav-link-icon-default {
color: #EF4444 !important;
}
/* Version link active state */
::deep .version-link.active,
::deep .version-link .active {
opacity: 1 !important;
}
::deep .version-link.active .version-text,
::deep .version-link .active .version-text {
color: #0EA5E9 !important;
}
/* Footer Section */
.nav-footer {
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding: 12px 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
background: rgba(15, 23, 42, 0.2);
}
/* User profile link */
.user-profile-link {
text-decoration: none !important;
color: inherit !important;
display: block;
cursor: pointer;
}
.user-profile-link:hover .user-profile-card {
background-color: rgba(255, 255, 255, 0.05);
border-radius: 12px;
}
.user-profile-card {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 20px;
margin-bottom: 8px;
box-sizing: border-box;
transition: all 0.25s ease;
}
.avatar-container {
position: relative;
display: inline-flex;
}
::deep .user-avatar {
width: 36px !important;
height: 36px !important;
background: linear-gradient(135deg, var(--mud-palette-secondary) 0%, var(--mud-palette-secondary-darken) 100%) !important;
color: #FFFFFF !important;
font-weight: 700 !important;
font-size: 0.875rem !important;
border: 2px solid rgba(255, 255, 255, 0.1) !important;
}
.status-dot {
position: absolute;
bottom: -1px;
right: -1px;
width: 10px;
height: 10px;
background-color: #10B981; /* Emerald 500 */
border: 2px solid var(--mud-palette-drawer-background);
border-radius: 50%;
}
.user-info {
display: flex;
flex-direction: column;
overflow: hidden;
transition: opacity 0.15s ease;
}
.user-name {
font-size: 0.85rem;
font-weight: 600;
color: var(--mud-palette-appbar-text);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.user-status {
font-size: 0.7rem;
color: #10B981;
}
/* Special links in footer */
::deep .logout-link .mud-nav-link-icon-default {
color: rgba(148, 163, 184, 0.6) !important;
}
::deep .logout-link:hover .mud-nav-link-icon-default {
color: #EF4444 !important;
}
::deep .version-link {
opacity: 0.7;
}
::deep .version-link .mud-nav-link-text {
font-size: 0.75rem !important;
display: flex !important;
align-items: center;
gap: 8px;
}
::deep .version-badge {
background-color: #EF4444;
color: white;
font-size: 0.65rem;
font-weight: 700;
min-width: 16px;
height: 16px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
box-shadow: 0 0 4px rgba(239, 68, 68, 0.4);
animation: pulse-badge 2s infinite;
}
@keyframes pulse-badge {
0% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7);
}
70% {
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0);
}
}
/* --- COLLAPSED DRAWER (MINI STATE) STYLES --- */
/* Parent matches the mini drawer element (Note the correct Blazor CSS isolation placement) */
.mud-drawer--closed ::deep .logo-icon,
.mud-drawer--closed ::deep .brand-text {
display: none !important;
opacity: 0;
}
.mud-drawer--closed ::deep .logo-wrapper {
justify-content: center !important;
}
.mud-drawer--closed ::deep .drawer-toggle-btn {
margin-left: 0 !important;
}
.mud-drawer--closed ::deep .admin-divider-container {
padding: 16px 0 8px 0 !important;
justify-content: center !important;
}
.mud-drawer--closed ::deep .admin-divider-text {
display: none !important;
}
.mud-drawer--closed ::deep .admin-divider-line {
width: 24px !important;
flex: none !important;
}
.mud-drawer--closed ::deep .user-profile-card {
justify-content: center !important;
padding: 8px 0 !important;
}
.mud-drawer--closed ::deep .user-info {
display: none !important;
}
.mud-drawer--closed ::deep .version-text {
display: none !important;
}
/* Ensure link text is hidden in mini mode to prevent clunky clipping */
.mud-drawer--closed ::deep .custom-nav-link .mud-nav-link-text {
display: none !important;
}
/* Center link icons when mini and adjust spacing */
.mud-drawer--closed ::deep .custom-nav-link {
margin: 4px 6px !important;
padding: 8px 0 !important;
justify-content: center !important;
min-height: 40px !important;
display: flex !important;
align-items: center !important;
}
.mud-drawer--closed ::deep .custom-nav-link .mud-nav-link-icon-default {
margin-right: 0 !important;
}
@@ -1,10 +1,14 @@
@page "/admin/users"
@rendermode 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>
@@ -22,7 +26,7 @@ else
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, #B71C1C 0%, #7F0000 100%); color:white;">
Style="background: #991B1B; color:white;">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudIcon Icon="@Icons.Material.Filled.AdminPanelSettings" Style="color:white; font-size:2rem" />
<MudStack Spacing="0">
@@ -42,6 +46,7 @@ else
<HeaderContent>
<MudTh><MudTableSortLabel SortBy="new Func<User, object>(u => u.Id)">ID</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortBy="new Func<User, object>(u => u.Username)" InitialDirection="SortDirection.Ascending">Benutzername</MudTableSortLabel></MudTh>
<MudTh>Firma / Mandant</MudTh>
<MudTh Style="text-align:right">Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
@@ -74,6 +79,34 @@ else
</MudStack>
}
</MudTd>
<MudTd>
@if (_editUserId == context.Id)
{
<MudSelect @bind-Value="_editTenantId" Margin="Margin.Dense" T="int?" Label="Firma zuweisen" Variant="Variant.Outlined" Style="max-width:250px">
<MudSelectItem T="int?" Value="@((int?)null)">Hauptdomain (System/Global)</MudSelectItem>
@foreach (var tenant in _tenants)
{
<MudSelectItem T="int?" Value="@((int?)tenant.Id)">@tenant.Name (@tenant.Subdomain)</MudSelectItem>
}
</MudSelect>
}
else
{
@if (context.TenantId.HasValue)
{
var t = _tenants.FirstOrDefault(x => x.Id == context.TenantId.Value);
<MudChip T="string" Color="Color.Primary" Variant="Variant.Outlined" Size="Size.Small">
@(t?.Name ?? $"Firma #{context.TenantId}")
</MudChip>
}
else
{
<MudChip T="string" Color="Color.Default" Variant="Variant.Text" Size="Size.Small">
@(context.Username == "marc" ? "SuperAdmin" : "Einzelnutzer")
</MudChip>
}
}
</MudTd>
<MudTd Style="text-align:right">
@if (_editUserId == context.Id)
{
@@ -92,6 +125,12 @@ else
Color="Color.Primary"
Size="Size.Small"
OnClick="@(() => StartEdit(context))" />
<MudTooltip Text="Passwort zurücksetzen">
<MudIconButton Icon="@Icons.Material.Filled.LockReset"
Color="Color.Warning"
Size="Size.Small"
OnClick="@(() => OpenResetPasswordDialog(context))" />
</MudTooltip>
@if (context.Username != "marc")
{
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline"
@@ -114,9 +153,11 @@ else
@code {
private List<User> _users = [];
private List<TenantStats> _tenants = [];
private bool _loading = true;
private int? _editUserId;
private string _editUsername = "";
private int? _editTenantId;
protected override async Task OnInitializedAsync()
{
@@ -125,13 +166,31 @@ else
if (claim == null) return;
UserNotificationService.OnUsersChanged += RefreshUsers;
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,17 +203,23 @@ 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)
{
@@ -162,9 +227,25 @@ else
return;
}
user.Username = trimmed;
}
// 2. Move to tenant if tenant ID changed
if (_editTenantId != user.TenantId)
{
var error = await AuthService.AssignTenantAsync(user.Id, _editTenantId);
if (error != null)
{
Snackbar.Add(error, Severity.Error);
return;
}
user.TenantId = _editTenantId;
}
_editUserId = null;
_editUsername = "";
Snackbar.Add($"Benutzer umbenannt zu \"{trimmed}\".", Severity.Success);
_editTenantId = null;
Snackbar.Add("Benutzerdaten erfolgreich aktualisiert.", Severity.Success);
}
private async Task DeleteUser(User user)
@@ -173,4 +254,29 @@ else
_users.Remove(user);
Snackbar.Add($"Benutzer \"{user.Username}\" gelöscht.", Severity.Info);
}
private async Task OpenResetPasswordDialog(User user)
{
var parameters = new DialogParameters<ResetPasswordDialog>
{
{ x => x.Username, user.Username }
};
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = await DialogService.ShowAsync<ResetPasswordDialog>("Passwort zurücksetzen", parameters, options);
var result = await dialog.Result;
if (result != null && !result.Canceled && result.Data is string newPassword)
{
var error = await AuthService.ResetPasswordAsync(user.Id, newPassword);
if (error != null)
{
Snackbar.Add(error, Severity.Error);
}
else
{
Snackbar.Add($"Passwort für {user.Username} wurde erfolgreich zurückgesetzt.", Severity.Success);
}
}
}
}
@@ -0,0 +1,139 @@
@page "/agb"
@rendermode InteractiveWebAssembly
@attribute [AllowAnonymous]
<PageTitle>AGB Timetracker</PageTitle>
<MudContainer MaxWidth="MaxWidth.Medium" Class="mt-8 pb-8">
<MudStack Spacing="4">
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudIcon Icon="@Icons.Material.Filled.Gavel" Style="color: var(--mud-palette-primary-text); font-size: 2.2rem;" />
<MudStack Spacing="0">
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight: 700;">Allgemeine Geschäftsbedingungen (AGB)</MudText>
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72;">
Rechtlich absolut unverbindliches Kauderwelsch und Flachwitze
</MudText>
</MudStack>
</MudStack>
</MudPaper>
@* ── Paragraphs with Flachwitze ── *@
<MudCard Elevation="2" Class="rounded-xl">
<MudCardContent>
<MudText Typo="Typo.h6" Style="font-weight: 700; color: var(--mud-palette-primary);" Class="mb-2">
§ 1 Geltungsbereich & Transparenz
</MudText>
<MudText Typo="Typo.body2" Class="mb-3">
Diese Bedingungen gelten für die gesamte Nutzung dieses Zeiterfassungs-Tools. Transparenz ist uns extrem wichtig.
</MudText>
<MudAlert Severity="Severity.Info" Dense="true" Class="rounded-lg">
<b>Warum können Geister eigentlich so schlecht lügen?</b><br />
Weil sie einfach extrem leicht zu durchschauen sind!
</MudAlert>
</MudCardContent>
</MudCard>
<MudCard Elevation="2" Class="rounded-xl">
<MudCardContent>
<MudText Typo="Typo.h6" Style="font-weight: 700; color: var(--mud-palette-primary);" Class="mb-2">
§ 2 Pflichten des Nutzers & Gärtnerklauseln
</MudText>
<MudText Typo="Typo.body2" Class="mb-3">
Jeder Nutzer verpflichtet sich, seine Arbeitszeiten wahrheitsgemäß und pünktlich einzutragen. Bei Zuwiderhandlung behalten wir uns vor, den Garten des Nutzers aufzuräumen.
</MudText>
<MudAlert Severity="Severity.Normal" Dense="true" Class="rounded-lg" Style="border-left: 4px solid #EF6C00; background: rgba(239, 108, 0, 0.05);">
<b>Was sagt ein Gärtner, wenn er seinen Arbeitsplatz vorzeitig verlässt?</b><br />
"Ich mach mich dann mal vom Acker!"
</MudAlert>
</MudCardContent>
</MudCard>
<MudCard Elevation="2" Class="rounded-xl">
<MudCardContent>
<MudText Typo="Typo.h6" Style="font-weight: 700; color: var(--mud-palette-primary);" Class="mb-2">
§ 3 Haftungsausschluss & Straßenverkehr
</MudText>
<MudText Typo="Typo.body2" Class="mb-3">
Wir übernehmen keinerlei Haftung für vergessene Pausenzeiten, verpasste Feierabende oder schlechtes Wetter.
</MudText>
<MudAlert Severity="Severity.Warning" Dense="true" Class="rounded-lg">
<b>Was ist rot, rund und steht am Straßenrand?</b><br />
Eine Spargelampel!<br /><br />
<b>Und warum dürfen Bienen eigentlich nicht in die Kirche?</b><br />
Weil sie in der Sekte sind!
</MudAlert>
</MudCardContent>
</MudCard>
<MudCard Elevation="2" Class="rounded-xl">
<MudCardContent>
<MudText Typo="Typo.h6" Style="font-weight: 700; color: var(--mud-palette-primary);" Class="mb-2">
§ 4 Datensicherheit & Physikalische Grundgesetze
</MudText>
<MudText Typo="Typo.body2" Class="mb-3">
Ihre Daten werden nach modernsten Verschlüsselungsstandards (nicht) an Dritte weitergegeben. Unsere Magnetbänder sind streng gesichert.
</MudText>
<MudAlert Severity="Severity.Normal" Dense="true" Class="rounded-lg" Style="border-left: 4px solid #00897B; background: rgba(0, 137, 123, 0.05);">
<b>Treffen sich zwei Magnete im Rechenzentrum. Sagt der eine ganz verzweifelt:</b><br />
"Du, sag mal... Was soll ich heute bloß anziehen?"
</MudAlert>
</MudCardContent>
</MudCard>
<MudCard Elevation="2" Class="rounded-xl">
<MudCardContent>
<MudText Typo="Typo.h6" Style="font-weight: 700; color: var(--mud-palette-primary);" Class="mb-2">
§ 5 Rückgaberecht & Büroalltag
</MudText>
<MudText Typo="Typo.body2" Class="mb-3">
Erfasste Arbeitszeiten können nicht zurückgenommen, umgetauscht oder bar ausgezahlt werden.
</MudText>
<MudAlert Severity="Severity.Normal" Dense="true" Class="rounded-lg" Style="border-left: 4px solid #5E35B1; background: rgba(94, 53, 177, 0.05);">
<b>Was macht ein gelangweilter Clown im Büro?</b><br />
Er macht Faxen!<br /><br />
<b>Und warum steht ein einsamer Pilz mitten im tiefen Wald?</b><br />
Weil die Tannen zapfen!
</MudAlert>
</MudCardContent>
</MudCard>
<MudCard Elevation="2" Class="rounded-xl">
<MudCardContent>
<MudText Typo="Typo.h6" Style="font-weight: 700; color: var(--mud-palette-primary);" Class="mb-2">
§ 6 Schlussbestimmungen & Salatgesetze
</MudText>
<MudText Typo="Typo.body2" Class="mb-3">
Sollten einzelne Bestimmungen dieser AGB unwirksam sein, bleibt die Fröhlichkeit aller anderen Bestimmungen unberührt.
</MudText>
<MudAlert Severity="Severity.Success" Dense="true" Class="rounded-lg">
<b>Wie nennt man ein plötzlich spurlos verschwundenes Stück Rindfleisch?</b><br />
Gulasch!<br /><br />
<b>Was ist grün, gesund und klopft nachts an die Haustür?</b><br />
Ein Klopfsalat!<br /><br />
<b>Und zu guter Letzt: Warum fliegen Vögel im Winter eigentlich nach Süden?</b><br />
Weil es zu Fuß einfach viel zu weit wäre!
</MudAlert>
</MudCardContent>
</MudCard>
@* ── Back Button ── *@
<MudStack Row="true" Justify="Justify.Center" Class="mt-4">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.ArrowBack" OnClick="GoBack" Style="border-radius: 20px;">
Zurück zur Anmeldung
</MudButton>
</MudStack>
</MudStack>
</MudContainer>
@code {
[Inject] private NavigationManager Nav { get; set; } = default!;
private void GoBack()
{
Nav.NavigateTo("/login");
}
}
@@ -1,24 +1,24 @@
@page "/changelog"
@rendermode InteractiveWebAssembly
@attribute [Authorize]
@attribute [AllowAnonymous]
@inject IJSRuntime JSRuntime
<PageTitle>Changelog Timetracker</PageTitle>
<MudStack Spacing="4">
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, #3F51B5 0%, #1A237E 100%); color:white;">
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudIcon Icon="@Icons.Material.Filled.NewReleases" Style="color:white; font-size:2rem" />
<MudIcon Icon="@Icons.Material.Filled.NewReleases" Style="color: var(--mud-palette-primary-text); font-size:2rem" />
<MudStack Spacing="0">
<MudText Typo="Typo.h5" Style="color:white; font-weight:700">Changelog</MudText>
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">Versionshistorie &amp; Änderungen</MudText>
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700">Changelog</MudText>
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">Versionshistorie &amp; Änderungen</MudText>
</MudStack>
</MudStack>
</MudPaper>
@foreach (var release in _releases)
@foreach (var release in Releases)
{
<MudCard Elevation="2" Class="rounded-xl">
<MudCardContent>
@@ -55,25 +55,33 @@
</MudStack>
@code {
private record ChangeEntry(string Tag, string Text);
private record Release(string Version, string Date, bool IsLatest, List<ChangeEntry> Entries);
public record ChangeEntry(string Tag, string Text);
public record Release(string Version, string Date, bool IsLatest, List<ChangeEntry> Entries, bool Highlighted = false);
private static Color GetTagColor(string tag) => tag switch
{
"Neu" => Color.Success,
"Fix" => Color.Error,
"Upgrade" => Color.Info,
_ => Color.Default
};
private readonly List<Release> _releases =
public static readonly List<Release> Releases =
[
new("1.3", "09.06.2026", true,
new("1.5", "25.06.2026", true,
[
new("Neu", "Userprofil angelegt"),
new("Upgrade", "Früheste Gehenszeit wird angezeigt"),
new("Upgrade", "Pausen werden dynamisch berechnet"),
new("Neu", "Excel-Export"),
new("Neu", "Multi-Tenant Integration"),
new("Upgrade", "Verbesserte Single-User Ansicht")
], Highlighted: true),
new("1.4", "08.06.2026", true,
[
new("Neu", "Timebot implementiert"),
new("Neu", "Onboarding Tour"),
new("Upgrade", "Security hardening"),
new("Upgrade", "Changed coloring"),
]),
new("1.3", "08.06.2026", false,
[
new("Fix", "Browser-Tab hat statischen Text angezeigt und sich nicht dynamisch an die jeweilige Seite angepasst"),
]),
new("1.2", "08.06.2026", true,
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"),
@@ -101,4 +109,30 @@
new("Neu", "Registrierung und Login"),
]),
];
public static string LatestVersion => Releases.FirstOrDefault()?.Version ?? "";
public static bool IsLatestHighlighted => Releases.FirstOrDefault()?.Highlighted ?? false;
private static Color GetTagColor(string tag) => tag switch
{
"Neu" => Color.Success,
"Fix" => Color.Error,
"Upgrade" => Color.Info,
_ => Color.Default
};
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "lastSeenVersion", LatestVersion);
}
catch
{
// Ignored in SSR
}
}
}
}
@@ -20,7 +20,7 @@ else
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, #00897B 0%, #004D40 100%); color: white;">
Style="background: #0F766E; color: white;">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
Style="color:white" Size="Size.Large" OnClick="PrevYear" />
@@ -140,7 +140,7 @@ else
@* ── Zusammenfassung ── *@
<MudPaper Elevation="2" Class="pa-4 rounded-xl"
Style="background: linear-gradient(90deg, rgba(0,137,123,0.08) 0%, transparent 100%); border-left: 4px solid #00897B;">
Style="background: linear-gradient(90deg, rgba(15,118,110,0.08) 0%, transparent 100%); border-left: 4px solid #0F766E;">
<MudStack Row="true" Spacing="4" Wrap="Wrap.Wrap" AlignItems="AlignItems.Center">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.Celebration" Style="color:#00897B" />
+221 -36
View File
@@ -5,6 +5,8 @@
@inject IHolidayService HolidayService
@inject ISnackbar Snackbar
@inject AuthenticationStateProvider AuthStateProvider
@inject IJSRuntime JSRuntime
@inject NavigationManager NavigationManager
<PageTitle>KW @_kw Wochenübersicht Timetracker</PageTitle>
@@ -20,29 +22,32 @@ else
<MudStack Spacing="3">
@* ── Wochen-Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, #3F51B5 0%, #1A237E 100%); color: white;">
<MudPaper Elevation="4" Class="pa-5 rounded-xl onboarding-week-header mud-theme-primary">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
Style="color:white" Size="Size.Large" OnClick="PrevWeek" />
Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="PrevWeek" />
<MudStack Spacing="0" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5" Style="color:white; font-weight:700; letter-spacing:0.5px">
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700; letter-spacing:0.5px">
@_weekLabel
</MudText>
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">
@_weekSubLabel
</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
<MudTooltip Text="Woche als Excel exportieren">
<MudIconButton Icon="@Icons.Material.Filled.Download"
Style="color: var(--mud-palette-primary-text)" OnClick="ExportWeekExcel" />
</MudTooltip>
@if (!IsCurrentWeek)
{
<MudButton Variant="Variant.Text" Style="color:white"
<MudButton Variant="Variant.Text" Style="color: var(--mud-palette-primary-text)"
OnClick="GoToCurrentWeek" Size="Size.Small">
Heute
</MudButton>
}
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
Style="color:white" Size="Size.Large" OnClick="NextWeek" />
Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="NextWeek" />
</MudStack>
</MudStack>
</MudPaper>
@@ -93,9 +98,9 @@ else
else
{
@* ── Arbeitstag: vollständige Karte ── *@
<MudCard @key="@day.Date" Elevation="@(isToday ? 6 : 2)" Class="rounded-xl"
<MudCard @key="@day.Date" Elevation="@(isToday ? 6 : 2)" Class="rounded-xl onboarding-day-card"
Style="@($"border-left: 4px solid {borderColor};")">
<MudCardHeader Style="@(isToday ? "background: linear-gradient(90deg, rgba(63,81,181,0.07) 0%, transparent 100%);" : "")">
<MudCardHeader Style="@(isToday ? "background: linear-gradient(90deg, rgba(14,165,233,0.07) 0%, transparent 100%);" : "")">
<CardHeaderContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudStack Spacing="0">
@@ -137,10 +142,31 @@ else
</MudChip>
}
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>
}
</MudStack>
</CardHeaderContent>
@@ -177,19 +203,43 @@ else
<MudText Color="Color.Secondary" Class="mb-1"></MudText>
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Color="Color.Secondary">Pausen</MudText>
<MudText Typo="Typo.body1">@FormatTs(day.TotalBreakTime)</MudText>
<MudText Typo="Typo.body1">@FormatTs(GetEffectiveBreakTime(day))</MudText>
</MudStack>
<MudText Color="Color.Secondary" Class="mb-1">=</MudText>
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Color="Color.Secondary">Netto</MudText>
<MudText Typo="Typo.body1" Color="Color.Primary" Style="font-weight:700">
@FormatTs(day.NetWork!.Value)
@FormatTs(GetEffectiveNetWork(day)!.Value)
</MudText>
</MudStack>
<MudDivider Vertical="true" FlexItem="true" Style="height:36px" />
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Color="Color.Secondary">Soll</MudText>
<MudText Typo="Typo.body1">@FormatTs(TimeSpan.FromHours(_settings.DailyTargetHours))</MudText>
<MudText Typo="Typo.body1">@FormatTs(TimeSpan.FromHours(GetDailyTargetHours(day)))</MudText>
</MudStack>
<MudDivider Vertical="true" FlexItem="true" Style="height:36px" />
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Color="Color.Secondary">Soll-Ende</MudText>
<MudText Typo="Typo.body1">@FormatTimeSpan(CalculateEarliestDeparture(day)) Uhr</MudText>
</MudStack>
</MudStack>
</MudItem>
}
else if (day.Start.HasValue)
{
<MudItem xs="12" md="8">
<MudStack Row="true" Spacing="3" AlignItems="AlignItems.Center" Class="h-100 pl-1">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-info);">Früheste Gehenszeit</MudText>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.ExitToApp" Color="Color.Info" Size="Size.Small" />
<MudText Typo="Typo.body1" Color="Color.Info" Style="font-weight:700">
@FormatTimeSpan(CalculateEarliestDeparture(day)) Uhr
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="ml-2">
(Bei Soll: @FormatTs(TimeSpan.FromHours(_settings.DailyTargetHours))h + @((int)GetRequiredBreakForTarget(day).TotalMinutes) Min. Pause)
</MudText>
</MudStack>
</MudStack>
</MudStack>
</MudItem>
@@ -199,7 +249,7 @@ else
@* ── Fortschrittsbalken ── *@
@if (progressPct >= 0)
{
<MudTooltip Text="@($"Netto {FormatTs(day.NetWork ?? TimeSpan.Zero)} von {FormatTs(TimeSpan.FromHours(_settings.DailyTargetHours))} Soll")">
<MudTooltip Text="@($"Netto {FormatTs(GetEffectiveNetWork(day) ?? TimeSpan.Zero)} von {FormatTs(TimeSpan.FromHours(GetDailyTargetHours(day)))} Soll")">
<MudProgressLinear Value="@Math.Min(progressPct, 100)"
Color="@(progressPct >= 100 ? Color.Success : Color.Primary)"
Rounded="true" Class="mt-3" Style="height:6px" />
@@ -269,35 +319,35 @@ else
}
@* ── Wochensumme ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, #1A237E 0%, #283593 100%); color:white;">
<MudPaper Elevation="4" Class="pa-5 rounded-xl onboarding-week-summary"
Style="background: var(--mud-palette-primary-darken); color: var(--mud-palette-primary-text);">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-4">
<MudIcon Icon="@Icons.Material.Filled.Summarize" Style="color:rgba(255,255,255,0.8)" />
<MudText Typo="Typo.h6" Style="color:white; font-weight:600">Wochensumme</MudText>
<MudIcon Icon="@Icons.Material.Filled.Summarize" Style="color: var(--mud-palette-primary-text); opacity: 0.8" />
<MudText Typo="Typo.h6" Style="color: var(--mud-palette-primary-text); font-weight:600">Wochensumme</MudText>
</MudStack>
<MudGrid Spacing="3">
<MudItem xs="6" sm="3">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Brutto</MudText>
<MudText Typo="Typo.h5" Style="color:white">@FormatTs(WeekGross)</MudText>
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-primary-text); opacity: 0.6">Brutto</MudText>
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text)">@FormatTs(WeekGross)</MudText>
</MudStack>
</MudItem>
<MudItem xs="6" sm="3">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Pausen</MudText>
<MudText Typo="Typo.h5" Style="color:white">@FormatTs(WeekBreaks)</MudText>
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-primary-text); opacity: 0.6">Pausen</MudText>
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text)">@FormatTs(WeekBreaks)</MudText>
</MudStack>
</MudItem>
<MudItem xs="6" sm="3">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Netto</MudText>
<MudText Typo="Typo.h5" Style="color:#90CAF9; font-weight:700">@FormatTs(WeekNet)</MudText>
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-primary-text); opacity: 0.6">Netto</MudText>
<MudText Typo="Typo.h5" Color="Color.Secondary" Style="font-weight:700">@FormatTs(WeekNet)</MudText>
</MudStack>
</MudItem>
<MudItem xs="6" sm="3">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Gleitzeit</MudText>
<MudText Typo="Typo.h5" Style="@($"color:{(WeekOvertime >= TimeSpan.Zero ? "#A5D6A7" : "#FFCC80")}; font-weight:700")">
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-primary-text); opacity: 0.6">Gleitzeit</MudText>
<MudText Typo="Typo.h5" Color="@(WeekOvertime >= TimeSpan.Zero ? Color.Success : Color.Warning)" Style="font-weight:700">
@FormatTs(WeekOvertime, sign: true)
</MudText>
</MudStack>
@@ -306,7 +356,7 @@ else
</MudPaper>
@* ── Gleitzeitkonto ── *@
<MudPaper Elevation="3" Class="pa-5 rounded-xl"
<MudPaper Elevation="3" Class="pa-5 rounded-xl onboarding-overtime-balance"
Style="@($"border-left: 6px solid {(_totalOvertime >= TimeSpan.Zero ? "#4CAF50" : "#FF9800")};")">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Wrap="Wrap.Wrap" Spacing="3">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
@@ -328,6 +378,11 @@ else
</MudPaper>
</MudStack>
@if (_showOnboarding)
{
<OnboardingTour OnFinished="HandleOnboardingFinished" />
}
}
@code {
@@ -347,6 +402,8 @@ else
private TimeSpan _totalOvertime;
private Dictionary<DateOnly, string> _holidays = [];
private int _holidayYear = -1;
private bool _showOnboarding;
private List<VacationDay> _vacations = [];
private bool IsCurrentWeek => _monday == GetMonday(DateOnly.FromDateTime(DateTime.Today));
@@ -364,9 +421,26 @@ else
await Task.WhenAll(loadWeekTask, overtimeTask);
_totalOvertime = await overtimeTask;
try
{
var showOnb = await JSRuntime.InvokeAsync<string>("localStorage.getItem", "showOnboarding");
_showOnboarding = showOnb == "true";
}
catch
{
// Ignored during prerendering/SSR
}
_loading = false;
}
private async Task HandleOnboardingFinished()
{
_showOnboarding = false;
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "showOnboarding", "false");
}
private async Task LoadWeek()
{
Task<List<PublicHoliday>> holidaysTask;
@@ -380,8 +454,9 @@ else
}
var dbDaysTask = TrackerService.GetWeekAsync(_userId, _monday);
var vacationsTask = TrackerService.GetVacationDaysAsync(_userId, _monday.Year);
await Task.WhenAll(holidaysTask, dbDaysTask);
await Task.WhenAll(holidaysTask, dbDaysTask, vacationsTask);
if (_monday.Year != _holidayYear)
{
@@ -390,11 +465,14 @@ else
_holidayYear = _monday.Year;
}
_vacations = await vacationsTask;
var dbDays = await dbDaysTask;
_days = Enumerable.Range(0, 7).Select(i =>
{
var date = _monday.AddDays(i);
return DayVm.From(dbDays.FirstOrDefault(d => d.Date == date), date, _userId);
var dayVm = DayVm.From(dbDays.FirstOrDefault(d => d.Date == date), date, _userId);
dayVm.IsVacation = _vacations.Any(v => v.Date == date);
return dayVm;
}).ToList();
BuildWeekLabels();
}
@@ -418,6 +496,10 @@ else
private async Task PrevWeek() { _monday = _monday.AddDays(-7); await LoadWeek(); }
private async Task NextWeek() { _monday = _monday.AddDays(7); await LoadWeek(); }
private async Task GoToCurrentWeek() { _monday = GetMonday(DateOnly.FromDateTime(DateTime.Today)); await LoadWeek(); }
private void ExportWeekExcel()
{
NavigationManager.NavigateTo($"api/tracker/export/week?monday={_monday:yyyy-MM-dd}", forceLoad: true);
}
private static DateOnly GetMonday(DateOnly date)
{
@@ -452,21 +534,61 @@ else
}
// ── Berechnungen ─────────────────────────────────────────────
private TimeSpan? GetOvertime(DayVm day) =>
day.NetWork.HasValue ? day.NetWork.Value - TimeSpan.FromHours(_settings.DailyTargetHours) : null;
private TimeSpan GetEffectiveBreakTime(DayVm day)
{
var enteredBreak = day.Breaks
.Where(b => b.Duration.HasValue)
.Aggregate(TimeSpan.Zero, (s, b) => s + b.Duration!.Value);
if (!day.Start.HasValue || !day.End.HasValue)
return enteredBreak;
var gross = day.End.Value - day.Start.Value;
int minMinutes = gross.TotalHours >= 9.0 ? 45 : 30;
int finalMin = Math.Max(minMinutes, _settings.MinimumBreakMinutes);
var minBreak = TimeSpan.FromMinutes(finalMin);
return enteredBreak > minBreak ? enteredBreak : minBreak;
}
private TimeSpan? GetEffectiveNetWork(DayVm day)
{
if (!day.GrossWork.HasValue) return null;
return day.GrossWork.Value - GetEffectiveBreakTime(day);
}
private double GetDailyTargetHours(DayVm day)
{
var isHoliday = _holidays.ContainsKey(day.Date);
var isWorkDay = _settings.IsWorkDay(day.Date.DayOfWeek);
if (!isWorkDay || isHoliday || day.IsVacation)
{
return 0.0;
}
return _settings.DailyTargetHours;
}
private TimeSpan? GetOvertime(DayVm day)
{
var net = GetEffectiveNetWork(day);
return net.HasValue ? net.Value - TimeSpan.FromHours(GetDailyTargetHours(day)) : null;
}
private int GetProgressPercent(DayVm day)
{
if (!day.NetWork.HasValue || _settings.DailyTargetHours <= 0) return -1;
return (int)(day.NetWork.Value.TotalHours / _settings.DailyTargetHours * 100);
var net = GetEffectiveNetWork(day);
var target = GetDailyTargetHours(day);
if (!net.HasValue || target <= 0) return -1;
return (int)(net.Value.TotalHours / target * 100);
}
private static string GetBorderColor(DayVm day, bool isWorkDay, List<string> errors)
private string GetBorderColor(DayVm day, bool isWorkDay, List<string> errors)
{
if (!isWorkDay) return "#CFD8DC";
if (errors.Count > 0) return "#EF5350";
if (day.IsVacation) return "#0EA5E9"; // Cool blue for vacation
if (!day.Start.HasValue && !day.End.HasValue) return "#B0BEC5";
if (!day.NetWork.HasValue) return "#B0BEC5";
if (!GetEffectiveNetWork(day).HasValue) return "#B0BEC5";
return "#4CAF50";
}
@@ -493,11 +615,17 @@ else
yield return $"Pause {i + 1} und Pause {j + 1} überschneiden sich.";
}
}
var net = GetEffectiveNetWork(day);
if (net.HasValue && net.Value.TotalHours > 10.0)
{
yield return "Die tägliche Arbeitszeit überschreitet das gesetzliche Maximum von 10 Stunden!";
}
}
private TimeSpan WeekGross => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (d.GrossWork ?? TimeSpan.Zero));
private TimeSpan WeekBreaks => _days.Aggregate(TimeSpan.Zero, (s, d) => s + d.TotalBreakTime);
private TimeSpan WeekNet => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (d.NetWork ?? TimeSpan.Zero));
private TimeSpan WeekBreaks => _days.Aggregate(TimeSpan.Zero, (s, d) => s + GetEffectiveBreakTime(d));
private TimeSpan WeekNet => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (GetEffectiveNetWork(d) ?? TimeSpan.Zero));
private TimeSpan WeekOvertime => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (GetOvertime(d) ?? TimeSpan.Zero));
private static string FormatTs(TimeSpan ts, bool sign = false)
@@ -508,6 +636,62 @@ else
return $"{prefix}{(int)abs.TotalHours}:{abs.Minutes:D2}";
}
private TimeSpan GetRequiredBreakForTarget(DayVm day)
{
var targetNet = _settings.DailyTargetHours;
var minMinutes = targetNet >= 8.5 ? 45 : 30;
var requiredBreak = TimeSpan.FromMinutes(Math.Max(minMinutes, _settings.MinimumBreakMinutes));
var enteredBreak = day.Breaks
.Where(b => b.Duration.HasValue)
.Aggregate(TimeSpan.Zero, (s, b) => s + b.Duration!.Value);
return enteredBreak > requiredBreak ? enteredBreak : requiredBreak;
}
private TimeSpan CalculateEarliestDeparture(DayVm day)
{
if (!day.Start.HasValue) return TimeSpan.Zero;
var target = TimeSpan.FromHours(_settings.DailyTargetHours);
var finalBreak = GetRequiredBreakForTarget(day);
return day.Start.Value + target + finalBreak;
}
private async Task MarkAsVacation(DayVm day)
{
var vacation = new VacationDay
{
UserId = _userId,
Date = day.Date,
Note = "Über Wochenübersicht eingetragen"
};
await TrackerService.AddVacationDayAsync(vacation);
await LoadWeek();
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
Snackbar.Add($"{day.Date:dd.MM.yyyy} als Urlaub eingetragen.", Severity.Success);
}
private async Task RemoveVacation(DayVm day)
{
var item = _vacations.FirstOrDefault(v => v.Date == day.Date);
if (item != null)
{
await TrackerService.RemoveVacationDayAsync(_userId, item.Id);
await LoadWeek();
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
Snackbar.Add($"Urlaub für {day.Date:dd.MM.yyyy} entfernt.", Severity.Info);
}
}
private static string FormatTimeSpan(TimeSpan? ts)
{
if (!ts.HasValue) return "";
var hours = ((int)ts.Value.TotalHours) % 24;
return $"{hours:D2}:{ts.Value.Minutes:D2}";
}
// ── ViewModels ────────────────────────────────────────────────
private sealed class DayVm
{
@@ -517,6 +701,7 @@ else
public TimeSpan? Start { get; set; }
public TimeSpan? End { get; set; }
public List<BreakVm> Breaks { get; set; } = [];
public bool IsVacation { get; set; }
public TimeSpan? GrossWork =>
Start.HasValue && End.HasValue && End > Start ? End.Value - Start.Value : null;
+406 -11
View File
@@ -1,22 +1,109 @@
@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">
@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:#1565C0" />
<MudText Typo="Typo.h4" Style="font-weight:700; color:#1565C0">Timetracker</MudText>
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))"
@@ -32,8 +119,8 @@
Registrieren
</MudButton>
</MudStack>
<MudDivider Class="mb-6" />
}
@if (_activeTab == 0)
{
@@ -79,9 +166,11 @@
</EditForm>
</MudStack>
}
else
else if (_activeTab == 1)
{
@* ── Register Form ── *@
@if (_tenantInfo.IsTenant)
{
@* ── Employee Register Form ── *@
<MudStack Spacing="3">
@if (_error != null)
{
@@ -106,6 +195,12 @@
@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"
@@ -113,7 +208,7 @@
Size="Size.Large"
StartIcon="@Icons.Material.Filled.PersonAdd"
Class="mt-2"
Disabled="_loading">
Disabled="_loading || !_acceptAgb">
@if (_loading)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
@@ -124,17 +219,177 @@
</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>
</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; }
@@ -142,6 +397,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
@@ -159,6 +428,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;
@@ -168,7 +466,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
{
@@ -187,13 +485,19 @@
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);
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
@@ -211,9 +515,100 @@
}
}
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; } = "";
}
}
+22 -10
View File
@@ -4,6 +4,7 @@
@inject ITimetrackerService TrackerService
@inject IHolidayService HolidayService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
<PageTitle>@_deCulture.DateTimeFormat.GetMonthName(_month) @_year Monatsübersicht Timetracker</PageTitle>
@@ -19,29 +20,32 @@ else
<MudStack Spacing="3">
@* ── Monats-Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, #3F51B5 0%, #1A237E 100%); color: white;">
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
Style="color:white" Size="Size.Large" OnClick="PrevMonth" />
Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="PrevMonth" />
<MudStack Spacing="0" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5" Style="color:white; font-weight:700; letter-spacing:0.5px">
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700; letter-spacing:0.5px">
@_deCulture.DateTimeFormat.GetMonthName(_month) @_year
</MudText>
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">
@_subLabel
</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
<MudTooltip Text="Monat als Excel exportieren">
<MudIconButton Icon="@Icons.Material.Filled.Download"
Style="color: var(--mud-palette-primary-text)" OnClick="ExportMonthExcel" />
</MudTooltip>
@if (!IsCurrentMonth)
{
<MudButton Variant="Variant.Text" Style="color:white"
<MudButton Variant="Variant.Text" Style="color: var(--mud-palette-primary-text)"
OnClick="GoToCurrentMonth" Size="Size.Small">
Heute
</MudButton>
}
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
Style="color:white" Size="Size.Large" OnClick="NextMonth" />
Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="NextMonth" />
</MudStack>
</MudStack>
</MudPaper>
@@ -51,7 +55,7 @@ else
<MudCardContent Class="pa-0">
<MudSimpleTable Dense="true" Striped="false" Hover="true" Style="overflow-x:auto">
<thead>
<tr style="background: rgba(63,81,181,0.08);">
<tr style="background: rgba(14,165,233,0.08);">
<th style="font-weight:700; padding:10px 16px">Tag</th>
<th style="font-weight:700; padding:10px 16px">Start</th>
<th style="font-weight:700; padding:10px 16px">Ende</th>
@@ -95,7 +99,7 @@ else
@* ── Monatszusammenfassung ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, rgba(63,81,181,0.08) 0%, rgba(26,35,126,0.04) 100%); border-left: 6px solid #3F51B5;">
Style="background: linear-gradient(135deg, color-mix(in srgb, var(--mud-palette-primary) 8%, transparent) 0%, color-mix(in srgb, var(--mud-palette-primary) 2%, transparent) 100%); border-left: 6px solid var(--mud-palette-primary);">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-4">
<MudIcon Icon="@Icons.Material.Filled.CalendarViewMonth" Color="Color.Primary" />
<MudText Typo="Typo.h6" Style="font-weight:700">Monatszusammenfassung</MudText>
@@ -210,7 +214,10 @@ else
.Where(b => b.StartTime.HasValue && b.EndTime.HasValue && b.EndTime > b.StartTime)
.Aggregate(TimeSpan.Zero, (s, b) =>
s + (b.EndTime!.Value.ToTimeSpan() - b.StartTime!.Value.ToTimeSpan()));
net = gross - breaks;
var minMinutes = gross.TotalHours >= 9.0 ? 45 : 30;
var minBreak = TimeSpan.FromMinutes(Math.Max(minMinutes, _settings.MinimumBreakMinutes));
var finalBreak = breaks > minBreak ? breaks : minBreak;
net = gross - finalBreak;
if (net < TimeSpan.Zero) net = null;
}
@@ -266,6 +273,11 @@ else
await LoadMonth();
}
private void ExportMonthExcel()
{
NavigationManager.NavigateTo($"api/tracker/export/month?year={_year}&month={_month}", forceLoad: true);
}
private static string FormatTs(TimeSpan ts, bool sign = false)
{
var neg = ts < TimeSpan.Zero;
@@ -0,0 +1,179 @@
@inject IJSRuntime JS
@if (_active)
{
@* Dark Backdrop Overlay *@
<div class="onboarding-backdrop"></div>
@* Onboarding Instruction Card *@
<div class="onboarding-card-container">
<MudCard Class="onboarding-info-card pa-5 rounded-xl shadow-2xl">
<MudCardContent Class="pa-0">
<MudStack Spacing="3">
@* Header / Title *@
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.AutoAwesome" Style="color: #0EA5E9;" />
<MudText Typo="Typo.h6" Style="font-weight: 700; color: white;">
@_steps[_currentStep].Title
</MudText>
</MudStack>
@* Explanation Text *@
<MudText Typo="Typo.body2" Style="color: #CBD5E1; line-height: 1.6; min-height: 60px;">
@_steps[_currentStep].Content
</MudText>
@* Footer / Progress & Buttons *@
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mt-2">
@* Step Count *@
<MudText Typo="Typo.caption" Style="color: #94A3B8; font-weight: 600;">
Schritt @(_currentStep + 1) von @_steps.Count
</MudText>
@* Navigation Button Stack *@
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
@if (_currentStep > 0)
{
<MudButton Variant="Variant.Text"
Style="color: #94A3B8; text-transform: none; border-radius: 12px;"
OnClick="PrevStep"
Size="Size.Small">
Zurück
</MudButton>
}
else
{
<MudButton Variant="Variant.Text"
Style="color: #94A3B8; text-transform: none; border-radius: 12px;"
OnClick="CompleteTour"
Size="Size.Small">
Überspringen
</MudButton>
}
<MudButton Variant="Variant.Filled"
Color="Color.Secondary"
Style="text-transform: none; border-radius: 12px; font-weight: 700; min-width: 80px;"
OnClick="NextStep"
Size="Size.Small">
@(_currentStep == _steps.Count - 1 ? "Fertig" : "Weiter")
</MudButton>
</MudStack>
</MudStack>
</MudStack>
</MudCardContent>
</MudCard>
</div>
}
<style>
/* Backdrop behind cards but below the spotlight highlight */
.onboarding-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(15, 23, 42, 0.4);
backdrop-filter: blur(2px);
z-index: 9999;
pointer-events: all;
}
/* Fixed positioned instruction box at the bottom center */
.onboarding-card-container {
position: fixed;
bottom: 32px;
left: 50%;
transform: translateX(-50%);
width: 90%;
max-width: 480px;
z-index: 10001;
transition: all 0.3s ease;
}
/* Dark theme styling for the instruction card */
.onboarding-info-card {
background: rgba(15, 23, 42, 0.9) !important;
backdrop-filter: blur(16px) !important;
-webkit-backdrop-filter: blur(16px) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.4) !important;
}
</style>
@code {
[Parameter] public EventCallback OnFinished { get; set; }
private bool _active = true;
private int _currentStep = 0;
private readonly List<OnboardingStep> _steps =
[
new(".onboarding-nav-menu", "Die Navigation", "Hier auf der linken Seite findest du das Hauptmenü. Du kannst ganz einfach zwischen der Wochen- und Monatsübersicht wechseln oder deine Statistiken einsehen."),
new(".onboarding-week-header", "Kalenderwoche blättern", "Hier siehst du die aktuell ausgewählte Woche. Verwende die Pfeiltasten links und rechts, um in vergangenen oder zukünftigen Wochen deine Zeiten zu erfassen."),
new(".onboarding-day-card", "Zeiterfassung pro Tag", "Das Herzstück des Timetrackers. Trage hier deine täglichen Start- und Endzeiten sowie deine Pausen ein. Das System errechnet die Zeiten und Überstunden in Echtzeit."),
new(".onboarding-week-summary", "Wochenzusammenfassung", "Hier siehst du auf einen Blick, wie viele Stunden du in dieser Woche gearbeitet hast, wie viel Pause du gemacht hast und wie sich dein Stundensaldo verändert hat."),
new(".onboarding-overtime-balance", "Gleitzeitkonto", "Hier wird dein gesamtes Gleitzeitkonto über das Jahr hinweg zusammengerechnet. So weißt du immer genau, wie viele Überstunden du angesammelt hast.")
];
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await HighlightStep();
}
}
private async Task HighlightStep()
{
if (_active && _steps.Count > 0)
{
var step = _steps[_currentStep];
await JS.InvokeVoidAsync("window.onboarding.highlight", step.TargetSelector);
}
}
private async Task NextStep()
{
if (_currentStep < _steps.Count - 1)
{
_currentStep++;
await HighlightStep();
}
else
{
await CompleteTour();
}
}
private async Task PrevStep()
{
if (_currentStep > 0)
{
_currentStep--;
await HighlightStep();
}
}
private async Task CompleteTour()
{
_active = false;
await JS.InvokeVoidAsync("window.onboarding.clear");
await OnFinished.InvokeAsync();
}
private class OnboardingStep
{
public string TargetSelector { get; }
public string Title { get; }
public string Content { get; }
public OnboardingStep(string targetSelector, string title, string content)
{
TargetSelector = targetSelector;
Title = title;
Content = content;
}
}
}
@@ -0,0 +1,280 @@
@page "/profile"
@rendermode InteractiveWebAssembly
@attribute [Authorize]
@using System.Security.Claims
@inject IAuthService AuthService
@inject ITimetrackerService TrackerService
@inject ISnackbar Snackbar
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Nav
<PageTitle>Benutzerprofil Timetracker</PageTitle>
@if (_loading)
{
<MudStack AlignItems="AlignItems.Center" Class="mt-16">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
<MudText Color="Color.Secondary">Lade Profil…</MudText>
</MudStack>
}
else
{
<MudStack Spacing="4">
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudIcon Icon="@Icons.Material.Filled.AccountCircle" Style="color: var(--mud-palette-primary-text); font-size:2.5rem" />
<MudStack Spacing="0">
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700">Mein Profil</MudText>
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72;">
Persönliche Details verwalten und Account sichern
</MudText>
</MudStack>
</MudStack>
</MudPaper>
<MudGrid Spacing="4">
@* ── Übersicht & Details ── *@
<MudItem xs="12" md="4">
<MudCard Elevation="3" Class="rounded-xl h-100">
<MudCardContent>
<MudStack AlignItems="AlignItems.Center" Class="py-6" Spacing="3">
<MudAvatar Style="width:96px; height:96px; font-size:2rem; font-weight:700; background: linear-gradient(135deg, #0EA5E9 0%, #0284C7 100%); color: white;">
@GetInitials(_username)
</MudAvatar>
<MudStack AlignItems="AlignItems.Center" Spacing="0">
<MudText Typo="Typo.h6" Style="font-weight:700">@_username</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">@(_isAdmin ? "Administrator" : "Standardbenutzer")</MudText>
</MudStack>
</MudStack>
<MudDivider Class="my-3" />
<MudStack Spacing="2" Class="pa-2">
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Color="Color.Secondary">Benutzer-ID</MudText>
<MudText Typo="Typo.body2"><b>#@_userId</b></MudText>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Color="Color.Secondary">Gleitzeit-Saldo</MudText>
<MudText Typo="Typo.body2" Color="@(_overtime >= TimeSpan.Zero ? Color.Success : Color.Warning)">
<b>@FormatTs(_overtime, sign: true)</b>
</MudText>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Color="Color.Secondary">Sollstunden / Tag</MudText>
<MudText Typo="Typo.body2"><b>@_settings.DailyTargetHours h</b></MudText>
</MudStack>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
@* ── Einstellungen verwalten ── *@
<MudItem xs="12" md="8">
<MudStack Spacing="4">
@* Benutzername ändern *@
<MudCard Elevation="3" Class="rounded-xl">
<MudCardHeader>
<CardHeaderContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.Badge" Color="Color.Primary" />
<MudText Typo="Typo.h6" Style="font-weight:600">Benutzername ändern</MudText>
</MudStack>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudForm @ref="_renameForm" @bind-IsValid="_renameIsValid">
<MudStack Spacing="3">
<MudTextField @bind-Value="_newUsername"
Label="Neuer Benutzername"
Variant="Variant.Outlined"
Required="true"
RequiredError="Bitte gib einen Benutzernamen ein."
Validation="@(new Func<string, string?>(ValidateUsername))" />
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="HandleRename"
Disabled="!_renameIsValid || _newUsername == _username"
Class="align-self-start rounded-lg py-2 px-4"
StartIcon="@Icons.Material.Filled.Save">
Speichern
</MudButton>
</MudStack>
</MudForm>
</MudCardContent>
</MudCard>
@* Passwort ändern *@
<MudCard Elevation="3" Class="rounded-xl">
<MudCardHeader>
<CardHeaderContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.Lock" Color="Color.Primary" />
<MudText Typo="Typo.h6" Style="font-weight:600">Passwort ändern</MudText>
</MudStack>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudForm @ref="_passwordForm" @bind-IsValid="_passwordIsValid">
<MudStack Spacing="3">
<MudTextField @bind-Value="_currentPassword"
Label="Aktuelles Passwort"
InputType="InputType.Password"
Variant="Variant.Outlined"
Required="true"
RequiredError="Bitte gib dein aktuelles Passwort ein." />
<MudTextField @bind-Value="_newPassword"
Label="Neues Passwort"
InputType="InputType.Password"
Variant="Variant.Outlined"
Required="true"
RequiredError="Bitte gib dein neues Passwort ein."
Validation="@(new Func<string, string?>(ValidatePassword))" />
<MudTextField @bind-Value="_confirmPassword"
Label="Neues Passwort bestätigen"
InputType="InputType.Password"
Variant="Variant.Outlined"
Required="true"
RequiredError="Bitte bestätige dein neues Passwort."
Validation="@(new Func<string, string?>(ValidateConfirmPassword))" />
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="HandleChangePassword"
Disabled="!_passwordIsValid"
Class="align-self-start rounded-lg py-2 px-4"
StartIcon="@Icons.Material.Filled.VpnKey">
Passwort ändern
</MudButton>
</MudStack>
</MudForm>
</MudCardContent>
</MudCard>
</MudStack>
</MudItem>
</MudGrid>
</MudStack>
}
@code {
private bool _loading = true;
private int _userId;
private string _username = "";
private string _newUsername = "";
private bool _isAdmin;
private AppSettings _settings = new();
private TimeSpan _overtime;
// Formulare
private MudForm? _renameForm;
private bool _renameIsValid;
private MudForm? _passwordForm;
private bool _passwordIsValid;
// Passwörter
private string _currentPassword = "";
private string _newPassword = "";
private string _confirmPassword = "";
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return;
_userId = int.Parse(claim.Value);
_username = authState.User.Identity?.Name ?? "";
_newUsername = _username;
_isAdmin = _username.Equals("marc", StringComparison.OrdinalIgnoreCase);
_settings = await TrackerService.GetSettingsAsync(_userId);
_overtime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
_loading = false;
}
private string GetInitials(string? name)
{
if (string.IsNullOrWhiteSpace(name)) return "U";
var parts = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 1) return parts[0][..Math.Min(2, parts[0].Length)].ToUpper();
return $"{parts[0][0]}{parts[1][0]}".ToUpper();
}
private static string FormatTs(TimeSpan ts, bool sign = false)
{
if (ts == TimeSpan.Zero && sign) return "±0:00";
var prefix = sign ? (ts >= TimeSpan.Zero ? "+" : "") : (ts < TimeSpan.Zero ? "" : "");
var abs = ts.Duration();
return $"{prefix}{(int)abs.TotalHours}:{abs.Minutes:D2}";
}
// Validierungen
private string? ValidateUsername(string val)
{
if (string.IsNullOrWhiteSpace(val) || val.Length < 3)
return "Benutzername muss mindestens 3 Zeichen lang sein.";
return null;
}
private string? ValidatePassword(string val)
{
if (string.IsNullOrWhiteSpace(val) || val.Length < 6)
return "Passwort muss mindestens 6 Zeichen lang sein.";
return null;
}
private string? ValidateConfirmPassword(string val)
{
if (val != _newPassword)
return "Die Passwörter stimmen nicht überein.";
return null;
}
// Aktionen
private async Task HandleRename()
{
if (_renameForm == null) return;
await _renameForm.ValidateAsync();
if (!_renameForm.IsValid) return;
var error = await AuthService.RenameUserAsync(_userId, _newUsername);
if (error != null)
{
Snackbar.Add(error, Severity.Error);
}
else
{
Snackbar.Add("Benutzername erfolgreich geändert. Bitte melde dich ggf. neu an, um alle Anzeigen zu aktualisieren.", Severity.Success);
_username = _newUsername;
// Umleitung zur Startseite, um die neue Identity zu laden
Nav.NavigateTo("/", forceLoad: true);
}
}
private async Task HandleChangePassword()
{
if (_passwordForm == null) return;
await _passwordForm.ValidateAsync();
if (!_passwordForm.IsValid) return;
var error = await AuthService.ChangePasswordAsync(_userId, _currentPassword, _newPassword);
if (error != null)
{
Snackbar.Add(error, Severity.Error);
}
else
{
Snackbar.Add("Passwort erfolgreich geändert.", Severity.Success);
_currentPassword = "";
_newPassword = "";
_confirmPassword = "";
if (_passwordForm != null) await _passwordForm.ResetAsync();
}
}
}
@@ -0,0 +1,29 @@
@namespace timetracker.Client.Components.Pages
@inject ISnackbar Snackbar
<MudDialog>
<DialogContent>
<MudText Class="mb-4">Geben Sie das neue Passwort für den Benutzer <strong>@Username</strong> ein.</MudText>
<MudTextField @bind-Value="_newPassword"
Label="Neues Passwort"
InputType="InputType.Password"
Variant="Variant.Outlined"
AutoFocus="true"
Required="true"
HelperText="Mindestens 6 Zeichen" />
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Abbrechen</MudButton>
<MudButton Color="Color.Warning" Variant="Variant.Filled" OnClick="Submit" Disabled="@(string.IsNullOrEmpty(_newPassword) || _newPassword.Length < 6)">Passwort zurücksetzen</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!;
[Parameter] public string Username { get; set; } = "";
private string _newPassword = "";
private void Submit() => MudDialog.Close(DialogResult.Ok(_newPassword));
private void Cancel() => MudDialog.Cancel();
}
@@ -5,6 +5,10 @@
@inject IHolidayService HolidayService
@inject ISnackbar Snackbar
@inject AuthenticationStateProvider AuthStateProvider
@inject IJSRuntime JSRuntime
@inject NavigationManager Nav
@inject HttpClient Http
<PageTitle>Einstellungen Timetracker</PageTitle>
@@ -19,13 +23,12 @@ else
<MudStack Spacing="4">
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, #3F51B5 0%, #1A237E 100%); color:white;">
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudIcon Icon="@Icons.Material.Filled.Settings" Style="color:white; font-size:2rem" />
<MudIcon Icon="@Icons.Material.Filled.Settings" Style="color: var(--mud-palette-primary-text); font-size:2rem" />
<MudStack Spacing="0">
<MudText Typo="Typo.h5" Style="color:white; font-weight:700">Einstellungen</MudText>
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700">Einstellungen</MudText>
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">
Arbeitszeit, Arbeitstage und Urlaub konfigurieren
</MudText>
</MudStack>
@@ -187,6 +190,79 @@ else
</MudCard>
</MudItem>
@* ── Hilfe & Einführung ── *@
<MudItem xs="12" md="6">
<MudCard Elevation="3" Class="rounded-xl h-100">
<MudCardHeader>
<CardHeaderContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.HelpOutline" Color="Color.Primary" />
<MudText Typo="Typo.h6" Style="font-weight:600">Hilfe & Einführung</MudText>
</MudStack>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudStack Spacing="4">
<MudText Typo="Typo.body2" Color="Color.Secondary">
Wenn du die interaktive Einführung (Onboarding Tour) noch einmal sehen möchtest, kannst du sie hier zurücksetzen und neu starten.
</MudText>
<MudButton Variant="Variant.Outlined" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.PlayCircleOutline"
OnClick="RepeatOnboarding"
Style="max-width:250px;">
Onboarding wiederholen
</MudButton>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
@if (_isTenantAdmin)
{
@* ── Firmen-Einstellungen (Branding) ── *@
<MudItem xs="12" md="6">
<MudCard Elevation="3" Class="rounded-xl h-100">
<MudCardHeader>
<CardHeaderContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.Business" Color="Color.Primary" />
<MudText Typo="Typo.h6" Style="font-weight:600">Firmen-Einstellungen</MudText>
</MudStack>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudStack Spacing="4">
<MudTextField @bind-Value="_tenantName"
Label="Firmenname"
Variant="Variant.Outlined"
HelperText="Der offizielle Name deiner Firma" />
<MudStack Row="true" Spacing="4">
<MudColorPicker @bind-Text="_tenantPrimaryColor"
Label="Primärfarbe"
Variant="Variant.Outlined"
Style="@($"color: {_tenantPrimaryColor};")"
ColorPickerMode="ColorPickerMode.HEX" />
<MudColorPicker @bind-Text="_tenantSecondaryColor"
Label="Sekundärfarbe"
Variant="Variant.Outlined"
Style="@($"color: {_tenantSecondaryColor};")"
ColorPickerMode="ColorPickerMode.HEX" />
</MudStack>
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Save"
OnClick="SaveTenantSettings"
Style="max-width:250px;">
Firma speichern
</MudButton>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
}
</MudGrid>
@* ── Speichern-Button ── *@
@@ -437,6 +513,11 @@ else
private AppSettings? _settings;
private int _userId;
private bool _isTenantAdmin;
private TenantInfo? _tenantInfo;
private string _tenantName = "";
private string _tenantPrimaryColor = "#0EA5E9";
private string _tenantSecondaryColor = "#0EA5E9";
private DateTime? _flexStartDate
{
get => _settings?.FlexTimeStartDate?.ToDateTime(TimeOnly.MinValue);
@@ -496,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);
@@ -578,6 +678,49 @@ else
Snackbar.Add("Feiertag entfernt", Severity.Info);
}
private async Task RepeatOnboarding()
{
try
{
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "showOnboarding", "true");
Snackbar.Add("Onboarding zurückgesetzt. Leite weiter zur Startseite...", Severity.Info);
Nav.NavigateTo("/");
}
catch (Exception ex)
{
Snackbar.Add($"Fehler beim Zurücksetzen des Onboardings: {ex.Message}", Severity.Error);
}
}
private async Task SaveTenantSettings()
{
if (string.IsNullOrWhiteSpace(_tenantName))
{
Snackbar.Add("Bitte einen Firmennamen eingeben.", Severity.Warning);
return;
}
var dto = new TenantSettingsDto
{
Name = _tenantName.Trim(),
PrimaryColor = _tenantPrimaryColor,
SecondaryColor = _tenantSecondaryColor
};
var response = await Http.PostAsJsonAsync("api/tracker/tenant/settings", dto);
if (response.IsSuccessStatusCode)
{
Snackbar.Add("Firmen-Einstellungen gespeichert. Lade Seite neu...", Severity.Success);
await Task.Delay(1000);
Nav.NavigateTo(Nav.Uri, forceLoad: true);
}
else
{
var err = await response.Content.ReadAsStringAsync();
Snackbar.Add($"Fehler beim Speichern: {err}", Severity.Error);
}
}
private static string FormatHours(double hours)
{
var ts = TimeSpan.FromHours(hours);
@@ -0,0 +1,481 @@
@page "/stats"
@rendermode InteractiveWebAssembly
@attribute [Authorize]
@inject ITimetrackerService TrackerService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@using System.Security.Claims
@using timetracker.Shared
<PageTitle>Statistiken Timetracker</PageTitle>
@if (_loading)
{
<MudStack AlignItems="AlignItems.Center" Class="mt-16" Spacing="3">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
<MudText Color="Color.Secondary">Lade Statistiken…</MudText>
</MudStack>
}
else
{
<MudStack Spacing="4">
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudIcon Icon="@Icons.Material.Filled.BarChart" Style="color: var(--mud-palette-primary-text); font-size:2.2rem" />
<MudStack Spacing="0">
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700">Statistiken</MudText>
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">Auswertung deiner Arbeitsleistung und Gleitzeitkonto</MudText>
</MudStack>
</MudStack>
</MudPaper>
<MudGrid Spacing="4">
@* ── Card 1: Wochen-Fortschritt ── *@
<MudItem xs="12" md="4">
<MudCard Elevation="3" Class="rounded-xl" Style="height:100%;">
<MudCardContent>
<MudStack AlignItems="AlignItems.Center" Spacing="3">
<MudText Typo="Typo.subtitle1" Style="font-weight:700; color:#475569">Wochenfortschritt</MudText>
@{
var pct = _weekTargetHours > 0 ? (int)Math.Min((_weekWorkedHours / _weekTargetHours) * 100, 100) : 0;
var displayPct = _weekTargetHours > 0 ? (int)Math.Round((_weekWorkedHours / _weekTargetHours) * 100) : 0;
}
<div style="position:relative; display:inline-flex;">
<MudProgressCircular Value="@pct" Color="Color.Secondary" Size="Size.Large" StrokeWidth="6" Style="height:120px; width:120px;" />
<div style="position:absolute; top:0; left:0; bottom:0; right:0; display:flex; align-items:center; justify-content:center; flex-direction:column;">
<MudText Typo="Typo.h5" Style="font-weight:800; color: var(--mud-palette-text-primary);">@displayPct%</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Erreicht</MudText>
</div>
</div>
<MudStack Spacing="1" Style="width:100%;" Class="mt-2">
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Color="Color.Secondary">Arbeitszeit (Ist):</MudText>
<MudText Typo="Typo.body2" Style="font-weight:700; color: var(--mud-palette-text-primary)">@FormatHours(_weekWorkedHours) Std.</MudText>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Color="Color.Secondary">Wochensoll (Soll):</MudText>
<MudText Typo="Typo.body2" Style="font-weight:700">@FormatHours(_weekTargetHours) Std.</MudText>
</MudStack>
</MudStack>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
@* ── Card 2: Wochensaldo ── *@
<MudItem xs="12" sm="6" md="4">
<MudCard Elevation="3" Class="rounded-xl" Style="height:100%; border-left: 6px solid var(--mud-palette-primary);">
<MudCardContent>
<MudStack Spacing="2">
<MudText Typo="Typo.subtitle1" Style="font-weight:700; color:#475569">Wochensaldo</MudText>
<MudStack Row="true" AlignItems="AlignItems.Center" Class="py-3">
<MudIcon Icon="@(_weekOvertimeHours >= 0 ? Icons.Material.Filled.TrendingUp : Icons.Material.Filled.TrendingDown)"
Style="@($"color:{(_weekOvertimeHours >= 0 ? "#10B981" : "#EF4444")}; font-size:3rem")" />
<MudStack Spacing="0">
<MudText Typo="Typo.h3" Style="@($"font-weight:800; color:{(_weekOvertimeHours >= 0 ? "#10B981" : "#EF4444")};")">
@FormatTs(TimeSpan.FromHours(_weekOvertimeHours), sign: true)
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Überstunden-Veränderung diese Woche</MudText>
</MudStack>
</MudStack>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
@* ── Card 3: Gesamtsaldo ── *@
<MudItem xs="12" sm="6" md="4">
<MudCard Elevation="3" Class="rounded-xl" Style="@($"height:100%; border-left: 6px solid {(_totalOvertime >= TimeSpan.Zero ? "#10B981" : "#EF4444")};")">
<MudCardContent>
<MudStack Spacing="2">
<MudText Typo="Typo.subtitle1" Style="font-weight:700; color:#475569">Gleitzeitkonto</MudText>
<MudStack Row="true" AlignItems="AlignItems.Center" Class="py-3">
<MudIcon Icon="Icons.Material.Filled.AccountBalance"
Style="@($"color:{(_totalOvertime >= TimeSpan.Zero ? "#10B981" : "#EF4444")}; font-size:3rem")" />
<MudStack Spacing="0">
<MudText Typo="Typo.h3" Style="@($"font-weight:800; color:{(_totalOvertime >= TimeSpan.Zero ? "#10B981" : "#EF4444")};")">
@FormatTs(_totalOvertime, sign: true)
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Gesamtsaldo aller erfassten Tage</MudText>
</MudStack>
</MudStack>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
@* ── Säulendiagramm (Tagesverteilung) ── *@
<MudItem xs="12">
<MudCard Elevation="3" Class="rounded-xl">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6" Style="font-weight:700; color: var(--mud-palette-text-primary)">Arbeitszeitverteilung diese Woche</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Geleistete Nettoarbeitsstunden pro Wochentag</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<div class="chart-container" style="width:100%; overflow-x:auto;">
<svg viewBox="0 0 640 320" width="100%" height="320" style="min-width: 500px; display: block; overflow: visible;">
<style>
.chart-bar {
transition: height 0.4s ease, y 0.4s ease, fill 0.3s;
cursor: pointer;
}
.chart-bar:hover {
filter: brightness(1.1) drop-shadow(0px 4px 8px rgba(14, 165, 233, 0.3));
}
</style>
@{
double plotLeft = 50;
double plotRight = 580;
double plotTop = 30;
double plotBottom = 270;
double plotWidth = plotRight - plotLeft;
double plotHeight = plotBottom - plotTop;
double barWidth = 36;
double spacing = plotWidth / 7;
}
<!-- Horizontal Grid Lines -->
@for (int i = 0; i <= 4; i++)
{
double val = (_maxHoursValue / 4.0) * i;
double yPos = plotBottom - (val / _maxHoursValue) * plotHeight;
<line x1="@F(plotLeft)" y1="@F(yPos)" x2="@F(plotRight)" y2="@F(yPos)" stroke="#E2E8F0" stroke-width="1" stroke-dasharray="2" />
@((MarkupString)$"<text x=\"{F(plotLeft - 8)}\" y=\"{F(yPos + 4)}\" fill=\"#64748B\" font-size=\"11\" font-weight=\"500\" text-anchor=\"end\">{val:F1}h</text>")
}
<!-- Bars and Labels -->
@for (int i = 0; i < 7; i++)
{
var d = _days[i];
double xPos = plotLeft + (i * spacing) + (spacing - barWidth) / 2;
double bHeight = (d.WorkedHours / _maxHoursValue) * plotHeight;
double yPos = plotBottom - bHeight;
string fillCol = "#94A3B8"; // Default light slate
if (d.IsToday)
{
fillCol = "var(--mud-palette-secondary)"; // Brand color for today
}
else if (d.WorkedHours >= d.TargetHours && d.TargetHours > 0)
{
fillCol = "#475569"; // Slate Blue for normal completed target
}
else if (d.WorkedHours > 0 && d.TargetHours == 0)
{
fillCol = "#10B981"; // Emerald Green for weekend work (pure overtime)
}
else if (d.WorkedHours > 0)
{
fillCol = "#64748B"; // Slate Medium for incomplete target
}
else
{
fillCol = "#E2E8F0"; // Very light grey for zero hours
}
<!-- Render Rect with hover tooltip -->
<rect x="@F(xPos)" y="@F(d.WorkedHours > 0 ? yPos : plotBottom - 4)" width="@F(barWidth)" height="@F(d.WorkedHours > 0 ? bHeight : 4)"
rx="6" fill="@fillCol" class="chart-bar">
<title>@d.DayName: @FormatHours(d.WorkedHours) Std. (Soll: @FormatHours(d.TargetHours) Std.)</title>
</rect>
<!-- Value Label above bar -->
@if (d.WorkedHours > 0)
{
@((MarkupString)$"<text x=\"{F(xPos + barWidth / 2)}\" y=\"{F(yPos - 6)}\" fill=\"var(--mud-palette-text-primary)\" font-size=\"11\" font-weight=\"700\" text-anchor=\"middle\">{FormatHours(d.WorkedHours)}</text>")
}
<!-- Wochentag Text -->
@((MarkupString)$"<text x=\"{F(xPos + barWidth / 2)}\" y=\"{F(plotBottom + 20)}\" fill=\"#64748B\" font-size=\"11\" font-weight=\"700\" text-anchor=\"middle\">{d.DayShortName}</text>")
}
<!-- Sollzeit Target Line (Only if target hours > 0) -->
@if (_settings.DailyTargetHours > 0)
{
double yTarget = plotBottom - (_settings.DailyTargetHours / _maxHoursValue) * plotHeight;
<line x1="@F(plotLeft)" y1="@F(yTarget)" x2="@F(plotRight)" y2="@F(yTarget)" stroke="#EF4444" stroke-width="2" stroke-dasharray="4" style="opacity: 0.75;" />
@((MarkupString)$"<text x=\"{F(plotRight - 10)}\" y=\"{F(yTarget - 6)}\" fill=\"#EF4444\" font-size=\"10\" font-weight=\"700\" text-anchor=\"end\">Soll ({_settings.DailyTargetHours} h)</text>")
}
</svg>
</div>
</MudCardContent>
</MudCard>
</MudItem>
@* ── Excel Export ── *@
<MudItem xs="12">
<MudCard Elevation="3" Class="rounded-xl">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6" Style="font-weight:700; color: var(--mud-palette-text-primary)">Excel-Datenexport</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Lade deine erfassten Arbeitszeiten in verschiedenen Zeiträumen als formatierte Excel-Datei herunter.</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudGrid Spacing="3">
@* Wochenexport *@
<MudItem xs="12" sm="4">
<MudStack Spacing="3">
<MudText Typo="Typo.subtitle2" Style="font-weight:700">Wochenexport</MudText>
<MudDatePicker Label="Woche wählen" @bind-Date="_exportWeekDate" PickerVariant="PickerVariant.Dialog" Variant="Variant.Outlined" Color="Color.Primary" />
<MudButton StartIcon="@Icons.Material.Filled.Download" Variant="Variant.Filled" Color="Color.Primary" OnClick="ExportWeek" FullWidth="true">
Woche exportieren
</MudButton>
</MudStack>
</MudItem>
@* Monatsexport *@
<MudItem xs="12" sm="4">
<MudStack Spacing="3">
<MudText Typo="Typo.subtitle2" Style="font-weight:700">Monatsexport</MudText>
<MudStack Row="true" Spacing="2">
<MudSelect T="int" Label="Monat" @bind-Value="_exportMonth" Variant="Variant.Outlined">
@for (int m = 1; m <= 12; m++)
{
var monthVal = m;
<MudSelectItem Value="@monthVal">@_deCulture.DateTimeFormat.GetMonthName(monthVal)</MudSelectItem>
}
</MudSelect>
<MudSelect T="int" Label="Jahr" @bind-Value="_exportMonthYear" Variant="Variant.Outlined">
@for (int y = DateTime.Today.Year - 5; y <= DateTime.Today.Year + 1; y++)
{
var yearVal = y;
<MudSelectItem Value="@yearVal">@yearVal</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudButton StartIcon="@Icons.Material.Filled.Download" Variant="Variant.Filled" Color="Color.Primary" OnClick="ExportMonth" FullWidth="true">
Monat exportieren
</MudButton>
</MudStack>
</MudItem>
@* Jahresexport *@
<MudItem xs="12" sm="4">
<MudStack Spacing="3">
<MudText Typo="Typo.subtitle2" Style="font-weight:700">Jahresexport</MudText>
<MudSelect T="int" Label="Jahr" @bind-Value="_exportYear" Variant="Variant.Outlined">
@for (int y = DateTime.Today.Year - 5; y <= DateTime.Today.Year + 1; y++)
{
var yearVal = y;
<MudSelectItem Value="@yearVal">@yearVal</MudSelectItem>
}
</MudSelect>
<MudButton StartIcon="@Icons.Material.Filled.Download" Variant="Variant.Filled" Color="Color.Primary" OnClick="ExportYear" FullWidth="true">
Jahr exportieren
</MudButton>
</MudStack>
</MudItem>
</MudGrid>
</MudCardContent>
</MudCard>
</MudItem>
@* ── Diagnostics Panel ── *@
<MudItem xs="12">
<MudExpansionPanels>
<MudExpansionPanel Text="Diagnose-Daten (Debug)">
<MudText Typo="Typo.body2">Benutzer-ID: @_userId</MudText>
<MudText Typo="Typo.body2">Wochensoll: @_weekTargetHours Std. | Netto-Arbeitszeit: @_weekWorkedHours Std. | Wochensaldo: @_weekOvertimeHours Std.</MudText>
<MudText Typo="Typo.body2">Anzahl erfasste Tage aus DB: @_rawDbDaysCount</MudText>
<MudSimpleTable Style="margin-top: 10px;">
<thead>
<tr>
<th>Datum</th>
<th>Tag</th>
<th>Ist (Std)</th>
<th>Soll (Std)</th>
<th>Ist > 0</th>
<th>Arbeitstag (Einst.)</th>
<th>Aus DB zugeordnet</th>
</tr>
</thead>
<tbody>
@foreach (var d in _days)
{
<tr>
<td>@d.Date.ToString("yyyy-MM-dd")</td>
<td>@d.DayName</td>
<td>@d.WorkedHours</td>
<td>@d.TargetHours</td>
<td>@(d.WorkedHours > 0 ? "Ja" : "Nein")</td>
<td>@(d.IsWorkDay ? "Ja" : "Nein")</td>
<td>@(_dbMatchStatus.GetValueOrDefault(d.Date, "Nein"))</td>
</tr>
}
</tbody>
</MudSimpleTable>
</MudExpansionPanel>
</MudExpansionPanels>
</MudItem>
</MudGrid>
</MudStack>
}
@code {
private bool _loading = true;
private int _userId;
private AppSettings _settings = new();
private List<DayStat> _days = [];
private TimeSpan _totalOvertime;
private double _weekWorkedHours;
private double _weekTargetHours;
private double _weekOvertimeHours;
private double _maxHoursValue = 10.0;
// Excel Export variables
private DateTime? _exportWeekDate = DateTime.Today;
private int _exportMonth = DateTime.Today.Month;
private int _exportMonthYear = DateTime.Today.Year;
private int _exportYear = DateTime.Today.Year;
// Debug variables
private int _rawDbDaysCount = 0;
private Dictionary<DateOnly, string> _dbMatchStatus = new();
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
private static string F(double value) => value.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return;
_userId = int.Parse(claim.Value);
_settings = await TrackerService.GetSettingsAsync(_userId);
var today = DateOnly.FromDateTime(DateTime.Today);
var monday = GetMonday(today);
var dbDays = await TrackerService.GetWeekAsync(_userId, monday);
_rawDbDaysCount = dbDays.Count;
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
_weekTargetHours = 0;
_weekWorkedHours = 0;
_dbMatchStatus.Clear();
_days = Enumerable.Range(0, 7).Select(i =>
{
var date = monday.AddDays(i);
bool isWorkDay = _settings.IsWorkDay(date.DayOfWeek);
double target = isWorkDay ? _settings.DailyTargetHours : 0.0;
if (isWorkDay)
{
_weekTargetHours += _settings.DailyTargetHours;
}
var wd = dbDays.FirstOrDefault(d => d.Date == date);
double worked = 0.0;
if (wd != null)
{
_dbMatchStatus[date] = $"Ja (Id={wd.Id}, Start={wd.StartTime}, Ende={wd.EndTime})";
if (wd.StartTime != null && wd.EndTime != null)
{
var gross = wd.EndTime.Value.ToTimeSpan() - wd.StartTime.Value.ToTimeSpan();
if (gross > TimeSpan.Zero)
{
var breakTotal = wd.Breaks
.Where(b => b.StartTime.HasValue && b.EndTime.HasValue && b.EndTime > b.StartTime)
.Aggregate(TimeSpan.Zero, (s, b) =>
s + (b.EndTime!.Value.ToTimeSpan() - b.StartTime!.Value.ToTimeSpan()));
var minMinutes = gross.TotalHours >= 9.0 ? 45 : 30;
var minBreak = TimeSpan.FromMinutes(Math.Max(minMinutes, _settings.MinimumBreakMinutes));
var finalBreak = breakTotal > minBreak ? breakTotal : minBreak;
worked = (gross - finalBreak).TotalHours;
if (worked < 0) worked = 0.0;
}
}
}
else
{
_dbMatchStatus[date] = "Nein";
}
_weekWorkedHours += worked;
return new DayStat
{
Date = date,
DayName = date.ToString("dddd", _deCulture),
DayShortName = date.ToString("ddd", _deCulture),
WorkedHours = worked,
TargetHours = target,
IsToday = date == today,
IsWorkDay = isWorkDay
};
}).ToList();
_weekOvertimeHours = _weekWorkedHours - _weekTargetHours;
var maxWorked = _days.Max(d => d.WorkedHours);
_maxHoursValue = Math.Max(10.0, Math.Max(maxWorked, _settings.DailyTargetHours));
_loading = false;
}
private static DateOnly GetMonday(DateOnly date)
{
int diff = ((int)date.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7;
return date.AddDays(-diff);
}
private string FormatHours(double hours)
{
var ts = TimeSpan.FromHours(hours);
return $"{(int)ts.TotalHours}:{ts.Minutes:D2}";
}
private string FormatTs(TimeSpan ts, bool sign = false)
{
if (ts == TimeSpan.Zero && sign) return "±0:00";
var prefix = sign ? (ts >= TimeSpan.Zero ? "+" : "") : (ts < TimeSpan.Zero ? "" : "");
var abs = ts.Duration();
return $"{prefix}{(int)abs.TotalHours}:{abs.Minutes:D2}";
}
private void ExportWeek()
{
if (_exportWeekDate.HasValue)
{
var date = DateOnly.FromDateTime(_exportWeekDate.Value);
NavigationManager.NavigateTo($"api/tracker/export/week?monday={date:yyyy-MM-dd}", forceLoad: true);
}
}
private void ExportMonth()
{
NavigationManager.NavigateTo($"api/tracker/export/month?year={_exportMonthYear}&month={_exportMonth}", forceLoad: true);
}
private void ExportYear()
{
NavigationManager.NavigateTo($"api/tracker/export/year?year={_exportYear}", forceLoad: true);
}
private sealed class DayStat
{
public DateOnly Date { get; set; }
public string DayName { get; set; } = "";
public string DayShortName { get; set; } = "";
public double WorkedHours { get; set; }
public double TargetHours { get; set; }
public bool IsToday { get; set; }
public bool IsWorkDay { get; set; }
}
}
@@ -0,0 +1,267 @@
@page "/superadmin"
@rendermode InteractiveWebAssembly
@attribute [Authorize(Policy = "AdminOnly")]
@using System.Net.Http.Json
@inject HttpClient Http
@inject ISnackbar Snackbar
@inject IDialogService DialogService
<PageTitle>Firmenverwaltung Timetracker SaaS</PageTitle>
@if (_loading)
{
<MudStack AlignItems="AlignItems.Center" Class="mt-16" Spacing="3">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
<MudText Color="Color.Secondary">Lade Firmen und Mandanten…</MudText>
</MudStack>
}
else
{
<MudStack Spacing="4">
@* ── Header Card ── *@
<MudPaper Elevation="4" Class="pa-6 rounded-xl"
Style="background: linear-gradient(135deg, var(--mud-palette-primary-darken) 0%, var(--mud-palette-primary) 100%); color: var(--mud-palette-primary-text);">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="4">
<MudIcon Icon="@Icons.Material.Filled.Business" Style="color: var(--mud-palette-secondary); font-size:3rem" />
<MudStack Spacing="0">
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:800">SuperAdmin Dashboard</MudText>
<MudText Typo="Typo.body2" Style="color: var(--mud-palette-primary-text); opacity: 0.72">
@_tenants.Count registrierte Firmen im System
</MudText>
</MudStack>
</MudStack>
</MudPaper>
@* ── Tenants Table ── *@
<MudCard Elevation="3" Class="rounded-xl overflow-hidden">
<MudCardContent Class="pa-0">
<MudTable Items="_tenants" Hover="true" Striped="true" Elevation="0" SortLabel="Sortieren">
<HeaderContent>
<MudTh Style="width: 50px;" />
<MudTh><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.Id)">ID</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.Name)">Name</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.Subdomain)">Subdomain</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.CreatedAt)">Erstellt am</MudTableSortLabel></MudTh>
<MudTh Style="text-align:center"><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.UserCount)">Benutzer</MudTableSortLabel></MudTh>
<MudTh Style="text-align:center"><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.WorkDaysCount)">Arbeitstage</MudTableSortLabel></MudTh>
<MudTh Style="text-align:center">Status</MudTh>
<MudTh Style="text-align:right">Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudIconButton Icon="@(context.ShowDetails ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)"
Size="Size.Small"
OnClick="@(() => context.ShowDetails = !context.ShowDetails)" />
</MudTd>
<MudTd DataLabel="ID">
<MudText Typo="Typo.body2" Color="Color.Secondary">@context.Id</MudText>
</MudTd>
<MudTd DataLabel="Name">
<MudText Typo="Typo.body1" Style="font-weight:600">@context.Name</MudText>
</MudTd>
<MudTd DataLabel="Subdomain">
<MudChip T="string" Color="Color.Primary" Variant="Variant.Outlined" Size="Size.Small">
@context.Subdomain
</MudChip>
</MudTd>
<MudTd DataLabel="Erstellt am">
<MudText Typo="Typo.body2">@context.CreatedAt.ToLocalTime().ToString("dd.MM.yyyy HH:mm")</MudText>
</MudTd>
<MudTd DataLabel="Benutzer" Style="text-align:center">
<MudBadge Content="@context.UserCount" Color="Color.Default" Overlap="false" Class="mx-2" />
</MudTd>
<MudTd DataLabel="Arbeitstage" Style="text-align:center">
<MudText Typo="Typo.body2">@context.WorkDaysCount</MudText>
</MudTd>
<MudTd DataLabel="Status" Style="text-align:center">
@if (context.IsApproved)
{
<MudChip T="string" Color="Color.Success" Size="Size.Small" Icon="@Icons.Material.Filled.Check">Aktiv</MudChip>
}
else
{
<MudChip T="string" Color="Color.Warning" Size="Size.Small" Icon="@Icons.Material.Filled.HourglassEmpty">Ausstehend</MudChip>
}
</MudTd>
<MudTd Style="text-align:right">
@if (!context.IsApproved)
{
<MudButton Variant="Variant.Filled"
Color="Color.Success"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.CheckCircle"
OnClick="@(() => ApproveTenant(context))"
Class="mr-1">
Freigeben
</MudButton>
}
else
{
<MudButton Variant="Variant.Outlined"
Color="Color.Warning"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.Block"
OnClick="@(() => ToggleActiveTenant(context))"
Class="mr-1">
Sperren
</MudButton>
}
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
Size="Size.Small"
OnClick="@(() => DeleteTenant(context))" />
</MudTd>
</RowTemplate>
<ChildRowContent>
@if (context.ShowDetails)
{
<MudTr>
<td colspan="9" style="background-color: var(--mud-palette-background-grey); padding: 16px;">
<MudPaper Elevation="1" Class="pa-4 rounded-lg">
<MudText Typo="Typo.subtitle2" Class="mb-3" Style="font-weight: 700;">
Benutzer für Firma: @context.Name
</MudText>
@if (context.Users == null || context.Users.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">Keine Benutzer in dieser Firma registriert.</MudText>
}
else
{
<MudGrid Spacing="2">
@foreach (var user in context.Users)
{
<MudItem xs="12" sm="6" md="4">
<MudPaper Outlined="true" Class="pa-3 rounded-lg d-flex align-center justify-space-between" Style="background: white;">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudAvatar Color="user.IsTenantAdmin ? Color.Primary : Color.Default" Size="Size.Small">
@user.Username.Substring(0, Math.Min(2, user.Username.Length)).ToUpper()
</MudAvatar>
<MudStack Spacing="0">
<MudText Typo="Typo.body2" Style="font-weight: 600;">@user.Username</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">ID: @user.Id</MudText>
</MudStack>
</MudStack>
@if (user.IsTenantAdmin)
{
<MudChip T="string" Color="Color.Primary" Size="Size.Small" Variant="Variant.Text" Icon="@Icons.Material.Filled.AdminPanelSettings">Admin</MudChip>
}
else
{
<MudChip T="string" Color="Color.Default" Size="Size.Small" Variant="Variant.Text" Icon="@Icons.Material.Filled.Person">Mitarbeiter</MudChip>
}
</MudPaper>
</MudItem>
}
</MudGrid>
}
</MudPaper>
</td>
</MudTr>
}
</ChildRowContent>
</MudTable>
</MudCardContent>
</MudCard>
</MudStack>
}
@code {
private bool _loading = true;
private List<TenantStats> _tenants = [];
protected override async Task OnInitializedAsync()
{
await LoadTenants();
}
private async Task LoadTenants()
{
_loading = true;
try
{
_tenants = await Http.GetFromJsonAsync<List<TenantStats>>("api/superadmin/tenants") ?? [];
}
catch (Exception ex)
{
Snackbar.Add($"Fehler beim Laden der Firmen: {ex.Message}", Severity.Error);
}
finally
{
_loading = false;
}
}
private async Task ApproveTenant(TenantStats tenant)
{
try
{
var response = await Http.PostAsync($"api/superadmin/tenants/{tenant.Id}/approve", null);
if (response.IsSuccessStatusCode)
{
tenant.IsApproved = true;
Snackbar.Add($"Firma '{tenant.Name}' wurde erfolgreich freigegeben.", Severity.Success);
}
else
{
Snackbar.Add("Fehler bei der Freigabe.", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"Fehler: {ex.Message}", Severity.Error);
}
}
private async Task ToggleActiveTenant(TenantStats tenant)
{
try
{
var response = await Http.PostAsync($"api/superadmin/tenants/{tenant.Id}/toggle-active", null);
if (response.IsSuccessStatusCode)
{
tenant.IsApproved = !tenant.IsApproved;
var stateMsg = tenant.IsApproved ? "aktiviert" : "gesperrt";
Snackbar.Add($"Firma '{tenant.Name}' wurde {stateMsg}.", Severity.Info);
}
else
{
Snackbar.Add("Fehler beim Ändern des Status.", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"Fehler: {ex.Message}", Severity.Error);
}
}
private async Task DeleteTenant(TenantStats tenant)
{
bool? result = await DialogService.ShowMessageBoxAsync(
"Firma unwiderruflich löschen?",
$"Sind Sie sicher, dass Sie die Firma '{tenant.Name}' ({tenant.Subdomain}) inklusive ALLER zugehörigen Benutzer, Einstellungen und Zeiterfassungen unwiderruflich löschen möchten? Dieser Vorgang kann nicht rückgängig gemacht werden!",
yesText: "Löschen", cancelText: "Abbrechen");
if (result == true)
{
try
{
var response = await Http.DeleteAsync($"api/superadmin/tenants/{tenant.Id}");
if (response.IsSuccessStatusCode)
{
_tenants.Remove(tenant);
Snackbar.Add($"Firma '{tenant.Name}' wurde gelöscht.", Severity.Success);
}
else
{
var err = await response.Content.ReadAsStringAsync();
Snackbar.Add($"Fehler beim Löschen: {err}", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"Fehler: {ex.Message}", Severity.Error);
}
}
}
}
@@ -21,7 +21,7 @@ else
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, #F57F17 0%, #E65100 100%); color:white;">
Style="background: #C2410C; color:white;">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
Style="color:white" Size="Size.Large" OnClick="PrevYear" />
@@ -32,9 +32,9 @@ public class ClientAuthService : IAuthService
return null;
}
public async Task<(User? User, string? Error)> RegisterAsync(string username, string password)
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 });
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>();
@@ -71,4 +71,48 @@ public class ClientAuthService : IAuthService
}
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;
}
}
@@ -15,7 +15,7 @@ public class ClientTimetrackerService : ITimetrackerService
public async Task<List<WorkDay>> GetWeekAsync(int userId, DateOnly monday)
{
return await _http.GetFromJsonAsync<List<WorkDay>>($"api/tracker/week?userId={userId}&monday={monday:yyyy-MM-dd}") ?? [];
return await _http.GetFromJsonAsync<List<WorkDay>>($"api/tracker/week?monday={monday:yyyy-MM-dd}") ?? [];
}
public async Task UpsertWorkDayAsync(WorkDay workDay)
@@ -29,7 +29,7 @@ public class ClientTimetrackerService : ITimetrackerService
{
return _cachedSettings;
}
var settings = await _http.GetFromJsonAsync<AppSettings>($"api/tracker/settings/{userId}");
var settings = await _http.GetFromJsonAsync<AppSettings>("api/tracker/settings");
_cachedSettings = settings ?? new AppSettings { UserId = userId };
return _cachedSettings;
}
@@ -42,7 +42,7 @@ public class ClientTimetrackerService : ITimetrackerService
public async Task<List<VacationDay>> GetVacationDaysAsync(int userId, int year)
{
return await _http.GetFromJsonAsync<List<VacationDay>>($"api/tracker/vacation/{userId}/{year}") ?? [];
return await _http.GetFromJsonAsync<List<VacationDay>>($"api/tracker/vacation/{year}") ?? [];
}
public async Task AddVacationDayAsync(VacationDay vacationDay)
@@ -52,12 +52,12 @@ public class ClientTimetrackerService : ITimetrackerService
public async Task RemoveVacationDayAsync(int userId, int id)
{
await _http.DeleteAsync($"api/tracker/vacation/{userId}/{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/{userId}", settings);
var response = await _http.PostAsJsonAsync("api/tracker/overtime", settings);
if (response.IsSuccessStatusCode)
{
var hours = await response.Content.ReadFromJsonAsync<double>();
@@ -68,6 +68,6 @@ public class ClientTimetrackerService : ITimetrackerService
public async Task<List<WorkDay>> GetMonthAsync(int userId, int year, int month)
{
return await _http.GetFromJsonAsync<List<WorkDay>>($"api/tracker/month?userId={userId}&year={year}&month={month}") ?? [];
return await _http.GetFromJsonAsync<List<WorkDay>>($"api/tracker/month?year={year}&month={month}") ?? [];
}
}
@@ -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);
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

+29 -1
View File
@@ -9,10 +9,19 @@
<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"]" />
<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>
@@ -20,6 +29,25 @@
<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>
+182 -8
View File
@@ -1,15 +1,23 @@
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) : IAuthService
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;
@@ -19,7 +27,9 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
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();
}
@@ -27,7 +37,7 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
public async Task DeleteUserAsync(int userId)
{
await using var db = await factory.CreateDbContextAsync();
var user = await db.Users.FindAsync(userId);
var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId);
if (user != null)
{
db.Users.Remove(user);
@@ -43,36 +53,200 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
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);
// 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<(User? User, string? Error)> RegisterAsync(string username, string password) {
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, "Benutzername bereits vergeben.");
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 };
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);
+278
View File
@@ -0,0 +1,278 @@
using ClosedXML.Excel;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using timetracker.Shared;
namespace timetracker.Data;
public static class ExcelExporter
{
public static async Task<byte[]> GenerateExcelExportAsync(
int userId,
DateOnly startDate,
DateOnly endDate,
string title,
ITimetrackerService trackerService,
TimetrackerDbContext db)
{
var settings = await trackerService.GetSettingsAsync(userId);
var workDays = await db.WorkDays
.Include(w => w.Breaks)
.Where(w => w.UserId == userId && w.Date >= startDate && w.Date <= endDate)
.ToDictionaryAsync(w => w.Date);
var holidaysList = await db.PublicHolidays
.Where(h => h.Date >= startDate && h.Date <= endDate)
.ToListAsync();
var state = settings.GermanState;
var holidayMap = holidaysList
.Where(h => string.IsNullOrEmpty(h.Counties) || (!string.IsNullOrEmpty(state) && h.Counties.Split(',').Contains(state)))
.ToDictionary(h => h.Date, h => h.Name);
var vacationMap = await db.VacationDays
.Where(v => v.UserId == userId && v.Date >= startDate && v.Date <= endDate)
.ToDictionaryAsync(v => v.Date, v => v.Note ?? "Urlaub");
var user = await db.Users.FindAsync(userId);
var userName = user != null ? user.Username : "Mitarbeiter";
var rows = new List<ExportRow>();
for (var date = startDate; date <= endDate; date = date.AddDays(1))
{
bool isWorkDay = settings.IsWorkDay(date.DayOfWeek);
bool isHoliday = holidayMap.ContainsKey(date);
bool isVacation = vacationMap.ContainsKey(date);
double targetHours = (isWorkDay && !isHoliday && !isVacation) ? settings.DailyTargetHours : 0.0;
string status = "Wochenende";
if (isHoliday) status = "Feiertag";
else if (isVacation) status = "Urlaub";
else if (isWorkDay) status = "Arbeitstag";
TimeSpan? startTime = null;
TimeSpan? endTime = null;
TimeSpan breakTime = TimeSpan.Zero;
double actualHours = 0.0;
string note = "";
if (isHoliday) note = holidayMap[date];
else if (isVacation) note = vacationMap[date];
if (workDays.TryGetValue(date, out var wd))
{
if (wd.StartTime.HasValue) startTime = wd.StartTime.Value.ToTimeSpan();
if (wd.EndTime.HasValue) endTime = wd.EndTime.Value.ToTimeSpan();
if (startTime.HasValue && endTime.HasValue)
{
var gross = endTime.Value - startTime.Value;
if (gross > TimeSpan.Zero)
{
var breakTotal = wd.Breaks
.Where(b => b.StartTime.HasValue && b.EndTime.HasValue && b.EndTime > b.StartTime)
.Aggregate(TimeSpan.Zero, (s, b) =>
s + (b.EndTime!.Value.ToTimeSpan() - b.StartTime!.Value.ToTimeSpan()));
var minMinutes = gross.TotalHours >= 9.0 ? 45 : 30;
var minBreak = TimeSpan.FromMinutes(Math.Max(minMinutes, settings.MinimumBreakMinutes));
var finalBreak = breakTotal > minBreak ? breakTotal : minBreak;
breakTime = finalBreak;
actualHours = (gross - finalBreak).TotalHours;
if (actualHours < 0) actualHours = 0.0;
}
}
}
rows.Add(new ExportRow
{
Date = date,
Status = status,
StartTime = startTime,
EndTime = endTime,
BreakTime = breakTime,
ActualHours = actualHours,
TargetHours = targetHours,
DiffHours = actualHours - targetHours,
Note = note
});
}
return ExportToExcel(title, userName, startDate, endDate, rows);
}
public static byte[] ExportToExcel(
string title,
string userName,
DateOnly startDate,
DateOnly endDate,
List<ExportRow> rows)
{
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Arbeitszeiten");
// Gitterlinien explizit einblenden
worksheet.ShowGridLines = true;
// Title Block
worksheet.Cell("A1").Value = title;
worksheet.Cell("A1").Style.Font.Bold = true;
worksheet.Cell("A1").Style.Font.FontSize = 16;
worksheet.Cell("A1").Style.Font.FontColor = XLColor.FromHtml("#0F172A"); // Slate 900
// Info Block
worksheet.Cell("A2").Value = $"Mitarbeiter: {userName}";
worksheet.Cell("A2").Style.Font.FontSize = 11;
worksheet.Cell("A2").Style.Font.Italic = true;
worksheet.Cell("A3").Value = $"Zeitraum: {startDate:dd.MM.yyyy} bis {endDate:dd.MM.yyyy}";
worksheet.Cell("A3").Style.Font.FontSize = 11;
worksheet.Cell("A3").Style.Font.Italic = true;
// Tabellenüberschriften (Zeile 5)
string[] headers = {
"Datum", "Wochentag", "Status", "Beginn", "Ende",
"Pause", "Netto (Ist)", "Soll-Stunden", "Differenz (Gleitzeit)", "Notiz / Feiertag / Urlaub"
};
for (int i = 0; i < headers.Length; i++)
{
var cell = worksheet.Cell(5, i + 1);
cell.Value = headers[i];
cell.Style.Font.Bold = true;
cell.Style.Font.FontColor = XLColor.White;
cell.Style.Fill.BackgroundColor = XLColor.FromHtml("#0EA5E9"); // Primary color (Sky 500)
cell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
}
// Datenzeilen
int currentRow = 6;
var deCulture = new CultureInfo("de-DE");
foreach (var r in rows)
{
worksheet.Cell(currentRow, 1).Value = r.Date.ToString("dd.MM.yyyy");
worksheet.Cell(currentRow, 2).Value = r.Date.ToString("dddd", deCulture);
worksheet.Cell(currentRow, 3).Value = r.Status;
if (r.StartTime.HasValue)
{
worksheet.Cell(currentRow, 4).Value = r.StartTime.Value.ToString(@"hh\:mm");
}
else
{
worksheet.Cell(currentRow, 4).Value = "—";
}
if (r.EndTime.HasValue)
{
worksheet.Cell(currentRow, 5).Value = r.EndTime.Value.ToString(@"hh\:mm");
}
else
{
worksheet.Cell(currentRow, 5).Value = "—";
}
worksheet.Cell(currentRow, 6).Value = r.BreakTime.TotalMinutes > 0 ? r.BreakTime.ToString(@"hh\:mm") : "—";
worksheet.Cell(currentRow, 7).Value = r.ActualHours;
worksheet.Cell(currentRow, 7).Style.NumberFormat.Format = "0.00";
worksheet.Cell(currentRow, 8).Value = r.TargetHours;
worksheet.Cell(currentRow, 8).Style.NumberFormat.Format = "0.00";
worksheet.Cell(currentRow, 9).Value = r.DiffHours;
worksheet.Cell(currentRow, 9).Style.NumberFormat.Format = "+0.00;-0.00;0.00";
if (r.DiffHours > 0)
{
worksheet.Cell(currentRow, 9).Style.Font.FontColor = XLColor.FromHtml("#16A34A"); // Green 600
worksheet.Cell(currentRow, 9).Style.Font.Bold = true;
}
else if (r.DiffHours < 0)
{
worksheet.Cell(currentRow, 9).Style.Font.FontColor = XLColor.FromHtml("#DC2626"); // Red 600
worksheet.Cell(currentRow, 9).Style.Font.Bold = true;
}
worksheet.Cell(currentRow, 10).Value = r.Note;
// Zebra-Streifen
if (currentRow % 2 == 0)
{
for (int col = 1; col <= headers.Length; col++)
{
worksheet.Cell(currentRow, col).Style.Fill.BackgroundColor = XLColor.FromHtml("#F8FAFC"); // Slate 50
}
}
// Rahmenlinien
for (int col = 1; col <= headers.Length; col++)
{
worksheet.Cell(currentRow, col).Style.Border.BottomBorder = XLBorderStyleValues.Thin;
worksheet.Cell(currentRow, col).Style.Border.BottomBorderColor = XLColor.FromHtml("#E2E8F0");
}
currentRow++;
}
// Summenzeile
currentRow++;
worksheet.Cell(currentRow, 6).Value = "Gesamt:";
worksheet.Cell(currentRow, 6).Style.Font.Bold = true;
worksheet.Cell(currentRow, 6).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Right;
var actualSumRange = $"G6:G{currentRow - 2}";
worksheet.Cell(currentRow, 7).FormulaA1 = $"SUM({actualSumRange})";
worksheet.Cell(currentRow, 7).Style.Font.Bold = true;
worksheet.Cell(currentRow, 7).Style.NumberFormat.Format = "0.00";
var targetSumRange = $"H6:H{currentRow - 2}";
worksheet.Cell(currentRow, 8).FormulaA1 = $"SUM({targetSumRange})";
worksheet.Cell(currentRow, 8).Style.Font.Bold = true;
worksheet.Cell(currentRow, 8).Style.NumberFormat.Format = "0.00";
var diffSumRange = $"I6:I{currentRow - 2}";
worksheet.Cell(currentRow, 9).FormulaA1 = $"SUM({diffSumRange})";
worksheet.Cell(currentRow, 9).Style.Font.Bold = true;
worksheet.Cell(currentRow, 9).Style.NumberFormat.Format = "+0.00;-0.00;0.00";
// Summenrahmen
for (int col = 6; col <= 9; col++)
{
worksheet.Cell(currentRow, col).Style.Border.TopBorder = XLBorderStyleValues.Thin;
worksheet.Cell(currentRow, col).Style.Border.TopBorderColor = XLColor.Black;
worksheet.Cell(currentRow, col).Style.Border.BottomBorder = XLBorderStyleValues.Double;
worksheet.Cell(currentRow, col).Style.Border.BottomBorderColor = XLColor.Black;
}
// Spaltenbreiten automatisch anpassen
worksheet.Columns().AdjustToContents();
using var memoryStream = new MemoryStream();
workbook.SaveAs(memoryStream);
return memoryStream.ToArray();
}
}
public class ExportRow
{
public DateOnly Date { get; set; }
public string Status { get; set; } = "";
public TimeSpan? StartTime { get; set; }
public TimeSpan? EndTime { get; set; }
public TimeSpan BreakTime { get; set; }
public double ActualHours { get; set; }
public double TargetHours { get; set; }
public double DiffHours { get; set; }
public string Note { get; set; } = "";
}
@@ -0,0 +1,10 @@
using System.Threading.Tasks;
using timetracker.Shared;
namespace timetracker.Data;
public interface ITenantProvider
{
int? TenantId { get; }
Task<Tenant?> GetCurrentTenantAsync();
}
@@ -0,0 +1,254 @@
// <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("20260624204954_AddMultiTenancy")]
partial class AddMultiTenancy
{
/// <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>("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<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PasswordSalt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TenantId");
b.ToTable("Users");
});
modelBuilder.Entity("timetracker.Shared.VacationDay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("VacationDays");
});
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<TimeOnly?>("EndTime")
.HasColumnType("TEXT");
b.Property<TimeOnly?>("StartTime")
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("WorkDays");
});
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
{
b.HasOne("timetracker.Shared.WorkDay", "WorkDay")
.WithMany("Breaks")
.HasForeignKey("WorkDayId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("WorkDay");
});
modelBuilder.Entity("timetracker.Shared.User", b =>
{
b.HasOne("timetracker.Shared.Tenant", "Tenant")
.WithMany()
.HasForeignKey("TenantId");
b.Navigation("Tenant");
});
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
{
b.Navigation("Breaks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,104 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace timetracker.Data.Migrations
{
/// <inheritdoc />
public partial class AddMultiTenancy : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "TenantId",
table: "WorkDays",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "TenantId",
table: "VacationDays",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "TenantId",
table: "Users",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "TenantId",
table: "AppSettings",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateTable(
name: "Tenants",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: false),
Subdomain = table.Column<string>(type: "TEXT", nullable: false),
IsApproved = table.Column<bool>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tenants", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Users_TenantId",
table: "Users",
column: "TenantId");
migrationBuilder.CreateIndex(
name: "IX_Tenants_Subdomain",
table: "Tenants",
column: "Subdomain",
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_Users_Tenants_TenantId",
table: "Users",
column: "TenantId",
principalTable: "Tenants",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Users_Tenants_TenantId",
table: "Users");
migrationBuilder.DropTable(
name: "Tenants");
migrationBuilder.DropIndex(
name: "IX_Users_TenantId",
table: "Users");
migrationBuilder.DropColumn(
name: "TenantId",
table: "WorkDays");
migrationBuilder.DropColumn(
name: "TenantId",
table: "VacationDays");
migrationBuilder.DropColumn(
name: "TenantId",
table: "Users");
migrationBuilder.DropColumn(
name: "TenantId",
table: "AppSettings");
}
}
}
@@ -0,0 +1,265 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using timetracker.Data;
#nullable disable
namespace timetracker.Data.Migrations
{
[DbContext(typeof(TimetrackerDbContext))]
[Migration("20260624210436_AddTenantBranding")]
partial class AddTenantBranding
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
modelBuilder.Entity("timetracker.Shared.AppSettings", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<double>("DailyTargetHours")
.HasColumnType("REAL");
b.Property<DateOnly?>("FlexTimeStartDate")
.HasColumnType("TEXT");
b.Property<double>("FlexTimeStartingBalanceHours")
.HasColumnType("REAL");
b.Property<string>("GermanState")
.HasColumnType("TEXT");
b.Property<int>("MinimumBreakMinutes")
.HasColumnType("INTEGER");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<int>("VacationDaysPerYear")
.HasColumnType("INTEGER");
b.Property<bool>("WorkFriday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkMonday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkSaturday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkSunday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkThursday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkTuesday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkWednesday")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("AppSettings");
});
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<TimeOnly?>("EndTime")
.HasColumnType("TEXT");
b.Property<TimeOnly?>("StartTime")
.HasColumnType("TEXT");
b.Property<int>("WorkDayId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("WorkDayId");
b.ToTable("BreakEntries");
});
modelBuilder.Entity("timetracker.Shared.PublicHoliday", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Counties")
.HasColumnType("TEXT");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("PublicHolidays");
});
modelBuilder.Entity("timetracker.Shared.Tenant", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsApproved")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PrimaryColor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SecondaryColor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Subdomain")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Subdomain")
.IsUnique();
b.ToTable("Tenants");
});
modelBuilder.Entity("timetracker.Shared.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("IsTenantAdmin")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PasswordSalt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TenantId");
b.ToTable("Users");
});
modelBuilder.Entity("timetracker.Shared.VacationDay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("VacationDays");
});
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<TimeOnly?>("EndTime")
.HasColumnType("TEXT");
b.Property<TimeOnly?>("StartTime")
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("WorkDays");
});
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
{
b.HasOne("timetracker.Shared.WorkDay", "WorkDay")
.WithMany("Breaks")
.HasForeignKey("WorkDayId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("WorkDay");
});
modelBuilder.Entity("timetracker.Shared.User", b =>
{
b.HasOne("timetracker.Shared.Tenant", "Tenant")
.WithMany()
.HasForeignKey("TenantId");
b.Navigation("Tenant");
});
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
{
b.Navigation("Breaks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,50 @@
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");
}
}
}
@@ -0,0 +1,265 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using timetracker.Data;
#nullable disable
namespace timetracker.Data.Migrations
{
[DbContext(typeof(TimetrackerDbContext))]
[Migration("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
}
}
}
@@ -0,0 +1,22 @@
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)
{
}
}
}
@@ -15,9 +15,9 @@ namespace timetracker.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
modelBuilder.Entity("timetracker.Data.AppSettings", b =>
modelBuilder.Entity("timetracker.Shared.AppSettings", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@@ -38,6 +38,9 @@ namespace timetracker.Data.Migrations
b.Property<int>("MinimumBreakMinutes")
.HasColumnType("INTEGER");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
@@ -70,7 +73,7 @@ namespace timetracker.Data.Migrations
b.ToTable("AppSettings");
});
modelBuilder.Entity("timetracker.Data.BreakEntry", b =>
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@@ -92,7 +95,7 @@ namespace timetracker.Data.Migrations
b.ToTable("BreakEntries");
});
modelBuilder.Entity("timetracker.Data.PublicHoliday", b =>
modelBuilder.Entity("timetracker.Shared.PublicHoliday", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@@ -113,12 +116,51 @@ namespace timetracker.Data.Migrations
b.ToTable("PublicHolidays");
});
modelBuilder.Entity("timetracker.Data.User", b =>
modelBuilder.Entity("timetracker.Shared.Tenant", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsApproved")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("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");
@@ -127,16 +169,21 @@ namespace timetracker.Data.Migrations
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TenantId");
b.ToTable("Users");
});
modelBuilder.Entity("timetracker.Data.VacationDay", b =>
modelBuilder.Entity("timetracker.Shared.VacationDay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@@ -148,6 +195,9 @@ namespace timetracker.Data.Migrations
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
@@ -156,7 +206,7 @@ namespace timetracker.Data.Migrations
b.ToTable("VacationDays");
});
modelBuilder.Entity("timetracker.Data.WorkDay", b =>
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@@ -171,6 +221,9 @@ namespace timetracker.Data.Migrations
b.Property<TimeOnly?>("StartTime")
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
@@ -179,9 +232,9 @@ namespace timetracker.Data.Migrations
b.ToTable("WorkDays");
});
modelBuilder.Entity("timetracker.Data.BreakEntry", b =>
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
{
b.HasOne("timetracker.Data.WorkDay", "WorkDay")
b.HasOne("timetracker.Shared.WorkDay", "WorkDay")
.WithMany("Breaks")
.HasForeignKey("WorkDayId")
.OnDelete(DeleteBehavior.Cascade)
@@ -190,7 +243,16 @@ namespace timetracker.Data.Migrations
b.Navigation("WorkDay");
});
modelBuilder.Entity("timetracker.Data.WorkDay", b =>
modelBuilder.Entity("timetracker.Shared.User", b =>
{
b.HasOne("timetracker.Shared.Tenant", "Tenant")
.WithMany()
.HasForeignKey("TenantId");
b.Navigation("Tenant");
});
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
{
b.Navigation("Breaks");
});
+126
View File
@@ -0,0 +1,126 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using timetracker.Shared;
namespace timetracker.Data;
public class TenantProvider : ITenantProvider
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IDbContextFactory<TimetrackerDbContext> _dbContextFactory;
private readonly IConfiguration _configuration;
public TenantProvider(
IHttpContextAccessor httpContextAccessor,
IDbContextFactory<TimetrackerDbContext> dbContextFactory,
IConfiguration configuration)
{
_httpContextAccessor = httpContextAccessor;
_dbContextFactory = dbContextFactory;
_configuration = configuration;
}
public int? TenantId
{
get
{
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext == null) return null;
if (httpContext.Items.TryGetValue("TenantId", out var cachedId))
{
return (int?)cachedId;
}
// Resolve and cache in HttpContext.Items
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,14 +1,72 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using timetracker.Shared;
namespace timetracker.Data;
public class TimetrackerDbContext(DbContextOptions<TimetrackerDbContext> options) : DbContext(options)
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);
}
}
@@ -173,7 +173,10 @@ public class TimetrackerService(IDbContextFactory<TimetrackerDbContext> factory)
.Where(b => b.StartTime.HasValue && b.EndTime.HasValue && b.EndTime > b.StartTime)
.Aggregate(TimeSpan.Zero, (s, b) =>
s + (b.EndTime!.Value.ToTimeSpan() - b.StartTime!.Value.ToTimeSpan()));
actual = (gross - breakTotal).TotalHours;
var minMinutes = gross.TotalHours >= 9.0 ? 45 : 30;
var minBreak = TimeSpan.FromMinutes(Math.Max(minMinutes, settings.MinimumBreakMinutes));
var finalBreak = breakTotal > minBreak ? breakTotal : minBreak;
actual = (gross - finalBreak).TotalHours;
if (actual < 0) actual = 0.0;
}
}
+504 -25
View File
@@ -1,4 +1,5 @@
using System.Security.Claims;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.HttpOverrides;
@@ -19,6 +20,9 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
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 =>
{
@@ -43,6 +47,8 @@ builder.Services.AddAuthorization(options =>
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddHttpContextAccessor();
builder.Services.AddTransient<ITenantProvider, TenantProvider>();
// Add SignalR
builder.Services.AddSignalR();
builder.Services.AddSingleton<UserNotificationService>();
@@ -59,6 +65,47 @@ builder.Services.AddRazorComponents()
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";
@@ -67,14 +114,16 @@ if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase))
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
?? builder.Configuration["ConnectionStrings:DefaultConnection"];
builder.Services.AddDbContextFactory<TimetrackerDbContext>(options =>
options.UseNpgsql(connectionString));
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}"));
options.UseSqlite($"Data Source={dbPath}")
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
}
var app = builder.Build();
@@ -84,13 +133,105 @@ using (var scope = app.Services.CreateScope())
{
var factory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<TimetrackerDbContext>>();
await using var db = await factory.CreateDbContextAsync();
if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase))
{
await db.Database.EnsureCreatedAsync();
}
else
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();
}
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Fehler beim Konvertieren der IsTenantAdmin-Spalte in PostgreSQL: {ex.Message}");
}
}
}
@@ -120,6 +261,7 @@ if (app.Configuration.GetValue("EnableHttpsRedirect", !app.Environment.IsDevelop
}
app.UseStaticFiles();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
@@ -141,49 +283,94 @@ app.MapGet("/api/auth/me", (HttpContext ctx) =>
{
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 });
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) =>
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.");
var claims = new[] {
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(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 });
});
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] LoginRequest req, IAuthService authService) =>
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[] {
var claims = new List<Claim> {
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username)
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 });
});
return Results.Ok(new UserInfo { Id = user.Id, Username = user.Username, IsTenantAdmin = user.IsTenantAdmin });
}).RequireRateLimiting("auth-limit");
app.MapGet("/auth/logout", async (HttpContext ctx) =>
{
@@ -216,11 +403,72 @@ usersApi.MapPut("/{userId:int}/rename", async (int userId, [FromBody] RenameRequ
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.MapGet("/week", async ([FromQuery] int userId, [FromQuery] string monday, ITimetrackerService trackerService) =>
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);
@@ -229,54 +477,159 @@ trackerApi.MapGet("/week", async ([FromQuery] int userId, [FromQuery] string mon
return Results.BadRequest("Ungültiges Datum.");
});
trackerApi.MapPost("/workday", async ([FromBody] WorkDay workDay, ITimetrackerService trackerService) =>
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/{userId:int}", async (int userId, ITimetrackerService trackerService) =>
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 ([FromBody] AppSettings settings, ITimetrackerService trackerService) =>
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/{userId:int}/{year:int}", async (int userId, int year, ITimetrackerService trackerService) =>
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 ([FromBody] VacationDay vacationDay, ITimetrackerService trackerService) =>
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/{userId:int}/{id:int}", async (int userId, int id, ITimetrackerService trackerService) =>
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/{userId:int}", async (int userId, [FromBody] AppSettings settings, ITimetrackerService trackerService) =>
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 ([FromQuery] int userId, [FromQuery] int year, [FromQuery] int month, ITimetrackerService trackerService) =>
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();
@@ -298,8 +651,134 @@ holidaysApi.MapDelete("/{id:int}", async (int id, IHolidayService holidayService
return Results.NoContent();
});
app.MapGet("/api/tenant/current", async (ITenantProvider tenantProvider) =>
{
var tenant = await tenantProvider.GetCurrentTenantAsync();
if (tenant == null)
{
return Results.Ok(new TenantInfo { IsTenant = false });
}
return Results.Ok(new TenantInfo
{
IsTenant = true,
Name = tenant.Name,
Subdomain = tenant.Subdomain,
IsApproved = tenant.IsApproved,
PrimaryColor = tenant.PrimaryColor,
SecondaryColor = tenant.SecondaryColor
});
});
app.MapPost("/api/auth/register-firm", async ([FromBody] RegisterFirmRequest req, IAuthService authService) =>
{
var (tenant, error) = await authService.RegisterFirmAsync(req);
if (tenant == null)
return Results.BadRequest(error ?? "Registrierung der Firma fehlgeschlagen.");
return Results.Ok(new { Success = true });
}).RequireRateLimiting("auth-limit");
// ── SuperAdmin-API-Endpoints (Protected) ───────────────────────────────────────────
var superAdminApi = app.MapGroup("/api/superadmin").RequireAuthorization("AdminOnly");
superAdminApi.MapGet("/tenants", async (TimetrackerDbContext db) =>
{
var tenants = await db.Tenants
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
var stats = new List<object>();
foreach (var t in tenants)
{
var userCount = await db.Users.IgnoreQueryFilters().CountAsync(u => u.TenantId == t.Id);
var workDaysCount = await db.WorkDays.IgnoreQueryFilters().CountAsync(w => w.TenantId == t.Id);
var tenantUsers = await db.Users.IgnoreQueryFilters()
.Where(u => u.TenantId == t.Id)
.Select(u => new { u.Id, u.Username, u.IsTenantAdmin })
.ToListAsync();
stats.Add(new
{
t.Id,
t.Name,
t.Subdomain,
t.IsApproved,
t.CreatedAt,
UserCount = userCount,
WorkDaysCount = workDaysCount,
Users = tenantUsers
});
}
return Results.Ok(stats);
});
superAdminApi.MapPost("/tenants/{id:int}/approve", async (int id, TimetrackerDbContext db) =>
{
var tenant = await db.Tenants.FindAsync(id);
if (tenant == null) return Results.NotFound();
tenant.IsApproved = true;
await db.SaveChangesAsync();
return Results.Ok();
});
superAdminApi.MapPost("/tenants/{id:int}/toggle-active", async (int id, TimetrackerDbContext db) =>
{
var tenant = await db.Tenants.FindAsync(id);
if (tenant == null) return Results.NotFound();
tenant.IsApproved = !tenant.IsApproved;
await db.SaveChangesAsync();
return Results.Ok();
});
superAdminApi.MapDelete("/tenants/{id:int}", async (int id, TimetrackerDbContext db, UserNotificationService notifier) =>
{
var tenant = await db.Tenants.FindAsync(id);
if (tenant == null) return Results.NotFound();
await using var transaction = await db.Database.BeginTransactionAsync();
try
{
var userIds = await db.Users.IgnoreQueryFilters()
.Where(u => u.TenantId == id)
.Select(u => u.Id)
.ToListAsync();
var workDays = await db.WorkDays.IgnoreQueryFilters().Where(w => w.TenantId == id).ToListAsync();
db.WorkDays.RemoveRange(workDays);
var vacationDays = await db.VacationDays.IgnoreQueryFilters().Where(v => v.TenantId == id).ToListAsync();
db.VacationDays.RemoveRange(vacationDays);
var settings = await db.AppSettings.IgnoreQueryFilters().Where(s => s.TenantId == id).ToListAsync();
db.AppSettings.RemoveRange(settings);
var users = await db.Users.IgnoreQueryFilters().Where(u => u.TenantId == id).ToListAsync();
db.Users.RemoveRange(users);
db.Tenants.Remove(tenant);
await db.SaveChangesAsync();
await transaction.CommitAsync();
foreach (var uid in userIds)
{
await notifier.NotifyUserDeletedAsync(uid);
}
await notifier.NotifyUsersChangedAsync();
return Results.NoContent();
}
catch (Exception ex)
{
await transaction.RollbackAsync();
return Results.BadRequest($"Fehler beim Löschen der Firma: {ex.Message}");
}
});
app.Run();
// ── Models for Request Bodies ──────────────────────────────────────────────────
public record LoginRequest(string Username, string Password);
public record RegisterRequest(string Username, string Password, string? Honeypot = null);
public record RenameRequest(string Username);
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
@@ -8,11 +8,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ClosedXML" Version="0.105.0" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
<PackageReference Include="MudBlazor" Version="9.4.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
</ItemGroup>
<ItemGroup>
+1
View File
@@ -4,6 +4,7 @@ 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,3 @@
namespace timetracker.Shared;
public record AssignTenantRequest(int? TenantId);
+5 -1
View File
@@ -6,5 +6,9 @@ public interface IAuthService
Task<List<User>> GetAllUsersAsync();
Task DeleteUserAsync(int userId);
Task<string?> RenameUserAsync(int userId, string newUsername);
Task<(User? User, string? Error)> RegisterAsync(string username, string password);
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);
}
@@ -0,0 +1,8 @@
namespace timetracker.Shared;
public record RegisterFirmRequest(
string CompanyName,
string Subdomain,
string AdminUsername,
string AdminPassword
);
@@ -0,0 +1,3 @@
namespace timetracker.Shared;
public record ResetPasswordRequest(string NewPassword);
+14
View File
@@ -0,0 +1,14 @@
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";
}
+11
View File
@@ -0,0 +1,11 @@
namespace timetracker.Shared;
public class TenantInfo
{
public bool IsTenant { get; set; }
public string? Name { get; set; }
public string? Subdomain { get; set; }
public bool IsApproved { get; set; }
public string? PrimaryColor { get; set; }
public string? SecondaryColor { get; set; }
}
+8
View File
@@ -0,0 +1,8 @@
namespace timetracker.Shared;
public class TenantSettingsDto
{
public string Name { get; set; } = "";
public string PrimaryColor { get; set; } = "#0EA5E9";
public string SecondaryColor { get; set; } = "#0EA5E9";
}
+23
View File
@@ -0,0 +1,23 @@
using System;
namespace timetracker.Shared;
public class TenantStats
{
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; }
public int UserCount { get; set; }
public int WorkDaysCount { get; set; }
public List<TenantUserDto> Users { get; set; } = new();
public bool ShowDetails { get; set; }
}
public class TenantUserDto
{
public int Id { get; set; }
public string Username { get; set; } = "";
public bool IsTenantAdmin { get; set; }
}
+4
View File
@@ -6,4 +6,8 @@ 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; }
}
+1
View File
@@ -4,4 +4,5 @@ public class UserInfo
{
public int Id { get; set; }
public string Username { get; set; } = "";
public bool IsTenantAdmin { get; set; }
}
+1
View File
@@ -4,6 +4,7 @@ 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
View File
@@ -4,6 +4,7 @@ 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; }
+4
View File
@@ -0,0 +1,4 @@
# TODOs für morgen
- [ ] **Single-User-Variante bauen:** Eine Möglichkeit implementieren, sich einfach und unabhängig von einem bestimmten Tenant zu registrieren.
- [ ] **Pricing-Modell konzipieren:** Gedanken über ein geeignetes Preismodell machen (z. B. SaaS-Preise, Mandanten-Preismodell).
-183
View File
@@ -1,183 +0,0 @@
# Blazor WASM Migration Aufwandsanalyse
> Analysiert am 08.06.2026
> Aktueller Stack: Blazor Server (.NET 10, InteractiveServer, SQLite, MudBlazor 9.4)
---
## TL;DR
Eine vollständige Migration auf Blazor WebAssembly ist **technisch möglich**, aber ein **erheblicher Umbau**. Der Aufwand liegt bei schätzungsweise **35 Wochen** für einen Entwickler. Der Hauptgrund: Blazor WASM läuft vollständig im Browser kein direkter DB-Zugriff, keine Cookie-Auth serverseitig, keine gemeinsamen In-Process-Events zwischen Nutzern.
**Empfehlung:** Hybrid-Modell (Blazor Auto oder WASM + ASP.NET Core API auf demselben Server) ist der pragmatischste Weg.
---
## Was WASM bringt
| Vorteil | Erklärung |
|---|---|
| Keine SignalR-Verbindung nötig | WASM lädt den Code einmalig herunter, kein dauerhafter WebSocket |
| Besser skalierbar | Server hält keine Circuits pro User im Speicher |
| Offline-fähig (theoretisch) | Progressive Web App möglich |
| Geringere Serverauslastung | UI-Logik läuft im Browser |
**Aber Achtung:** Der initiale Download ist größer (~1020 MB .NET Runtime im Browser), erste Ladezeit spürbar langsamer als Blazor Server.
---
## Aktuelle Blocker (nach Schwere)
### 🔴 KRITISCH muss komplett umgebaut werden
#### 1. Datenbankzugriff EF Core / SQLite
Alle drei Services greifen direkt per `IDbContextFactory<TimetrackerDbContext>` auf die SQLite-DB zu:
- `AuthService` → Login, Registrierung, User-CRUD
- `TimetrackerService` → Arbeitstage, Pausen, Einstellungen, Urlaub, Überstunden
- `HolidayService` → Feiertage speichern/lesen
**WASM kann nicht direkt auf eine Datenbank zugreifen.** Browser hat keinen Dateisystemzugriff.
**Lösung:** Alle Service-Methoden als REST-API-Endpoints auf dem Server exponieren. WASM-Client ruft diese per `HttpClient` auf.
**Aufwand:** ~1015 Tage (je nach gewähltem API-Stil: Minimal API vs. Controller)
---
#### 2. Cookie-Authentifizierung
Aktuell läuft der gesamte Auth-Flow serverseitig:
- `POST /auth/login``HttpContext.SignInAsync()` → Cookie setzen
- `GET /auth/logout``HttpContext.SignOutAsync()`
- Blazor-Komponenten lesen den Cookie über `CascadingAuthenticationState`
In WASM gibt es keinen direkten Zugriff auf `HttpContext`. Die Cookie-Auth muss weiterhin **serverseitig** verbleiben, aber das Login-Formular muss über die API kommunizieren.
**Lösung A (einfacher):** Cookie-Auth beibehalten, aber Login-Formular als API-Call (kein HTML-Form-Post mehr). WASM erhält Auth-State über einen `/auth/me`-Endpoint.
**Lösung B (aufwändiger):** JWT-Token-Auth einführen, Token im Browser-Speicher halten.
**Empfehlung:** Lösung A, da Cookie-Auth bereits implementiert ist und sicherer gegen XSS ist als localStorage-JWTs.
**Aufwand:** ~34 Tage
---
#### 3. Projektstruktur neue Build-Konfiguration
Aktuell: 1 Projekt (`Microsoft.NET.Sdk.Web`), alles in einem.
WASM benötigt mindestens:
- **`timetracker.Client`** Blazor WASM-Projekt (läuft im Browser)
- **`timetracker.Server`** ASP.NET Core-Projekt (hostet API + WASM-App)
- Optional: **`timetracker.Shared`** DTOs/Models geteilt zwischen Client & Server
Alle `@inject`-Direktiven, die auf Server-Services zeigen, müssen im Client auf `HttpClient`-basierte Services umgestellt werden.
**Aufwand:** ~34 Tage (Projektaufspaltung, DI-Umverdrahtung)
---
### ⚠️ MITTEL umbaubar, aber aufwändig
#### 4. UserNotificationService (Live-Updates)
Aktuell: Singleton mit C#-Events, der zwischen Blazor Server-Circuits kommuniziert:
- Admin löscht User → Event feuert → alle aktiven `MainLayout`-Instanzen reagieren
- Neuer User registriert → `AdminUsers` aktualisiert sich live
In WASM gibt es keine gemeinsamen In-Process-Events. Jeder Browser ist isoliert.
**Lösung:** **SignalR Hub** einführen. Server sendet Push-Nachrichten an alle verbundenen Clients.
```
[Browser A Admin] [Server] [Browser B normaler User]
löscht User C → Hub.Clients.All.SendAsync → empfängt "UserDeleted(C)"
→ NavigateTo("/auth/logout")
```
**Aufwand:** ~23 Tage (Hub einrichten, Client-seitig `HubConnection` einbauen)
---
#### 5. Formular-basiertes Login/Register
Login und Registrierung verwenden aktuell native HTML-Forms (`method="post"`) die direkt an `/auth/login` und `/auth/register` posten. Das funktioniert in WASM nicht mehr sauber, weil WASM SPA-Navigation nutzt.
**Lösung:** Login-Formular auf `@code`-basierten `HttpClient.PostAsync`-Call umstellen, Redirect über `NavigationManager`.
**Aufwand:** ~1 Tag
---
#### 6. Docker-Deployment
Aktuell: 1 Container, alles drin.
WASM + API = zwei Artefakte:
- Die WASM-App sind statische Dateien (HTML/JS/WASM)
- Die API läuft als ASP.NET Core-Server
**Lösung A (einfach):** Hosted WASM API-Server liefert auch die WASM-Dateien aus (1 Container, 1 Dockerfile, wie heute)
**Lösung B (komplex):** Getrennte Container (Nginx für WASM, API separat)
**Empfehlung:** Lösung A (Hosted WASM) minimaler Deployment-Overhead.
**Aufwand:** ~0,5 Tage bei Lösung A
---
### ✅ KEIN UMBAU NÖTIG
| Komponente | Warum kein Umbau |
|---|---|
| **MudBlazor 9.4** | Vollständig WASM-kompatibel |
| **Alle `.razor`-Seiten (UI)** | Markup bleibt identisch, nur `@inject` ändert sich |
| **Entity Models** (User, WorkDay, etc.) | Können ins Shared-Projekt verschoben werden |
| **HttpClient für Feiertage** (nager.at) | Funktioniert in WASM nativ |
| **Business-Logik in Komponenten** | Bleibt unverändert |
| **CSS / MudBlazor Theme** | Unverändert |
---
## Gesamtaufwand-Schätzung
| Aufgabe | Geschätzter Aufwand |
|---|---|
| Projektstruktur aufteilen (Client/Server/Shared) | 23 Tage |
| REST-API für TimetrackerService | 45 Tage |
| REST-API für AuthService + HolidayService | 23 Tage |
| Cookie-Auth auf API-Flow umstellen | 23 Tage |
| Client-Services (HttpClient-basiert) erstellen | 34 Tage |
| SignalR für Live-Notifications | 23 Tage |
| Login/Register-Formulare anpassen | 1 Tag |
| Docker/Deployment anpassen | 0,51 Tag |
| Testen & Debugging | 23 Tage |
| **Gesamt** | **~1826 Tage** |
---
## Empfohlene Alternative: Blazor Auto
.NET 8+ bietet **Blazor Auto**-Rendermodus: Seiten starten als Server-Rendering, wechseln dann automatisch zu WASM sobald die Runtime heruntergeladen ist. Das gibt sofortige Interaktivität (Server) + langfristig WASM-Performance.
**Vorteil:** DB-Zugriff und Auth bleiben wie heute, kein API-Layer nötig.
**Aufwand:** ~35 Tage (Projektstruktur, Shared-Projekt, WASM-Client-Projekt anlegen).
**Für dieses Projekt sehr empfehlenswert**, da:
1. Minimaler Umbau der bestehenden Logik
2. Spürbare Performance-Verbesserung nach erstem Laden
3. Kein SignalR-Umbau notwendig (Server-Circuit bleibt beim ersten Laden aktiv)
---
## Fazit
| Option | Aufwand | Performance-Gewinn | Empfehlung |
|---|---|---|---|
| Blazor WASM (pure) | ~2025 Tage | Hoch (nach erstem Laden) | Nur bei hoher Nutzerzahl sinnvoll |
| Blazor Auto | ~35 Tage | Mittel-Hoch | ✅ **Empfohlen** |
| Blazor Server (wie heute) | 0 | | Gut für kleine Nutzerzahl |
Für den aktuellen Use-Case (internes Uni-Tool, überschaubare Nutzerzahl) ist **Blazor Auto** das beste Kosten-Nutzen-Verhältnis. Eine vollständige WASM-Migration lohnt sich erst, wenn die App deutlich mehr parallele User hat oder offline-Fähigkeit gefordert wird.