7 Commits

Author SHA1 Message Date
mrcwlnd 73599c00d1 docker-compose.yml aktualisiert 2026-05-22 07:36:19 +00:00
mrcwlnd 64c5f6aa2c docker-compose.yml aktualisiert 2026-05-22 07:29:54 +00:00
mrcwlnd 5431618d43 Dockerfile aktualisiert 2026-05-22 07:29:38 +00:00
mrcwlnd 598243ddc9 docker-compose.yml hinzugefügt 2026-05-22 07:27:32 +00:00
Wieland, Marc 9fd50f86c0 added dockerfile 2 2026-05-22 09:22:47 +02:00
Wieland, Marc 0467f45036 Added dockerfile 2026-05-22 09:21:57 +02:00
Wieland, Marc 88ac175190 first commit 2026-05-22 09:18:01 +02:00
297 changed files with 6304 additions and 1739 deletions
-11
View File
@@ -1,11 +0,0 @@
# SQLite Datenbanken und Journal-Dateien
*.db
*.db-shm
*.db-wal
# Build-Artefakte
bin/
obj/
*.user
*.suo
*.vs/
-25
View File
@@ -1,9 +1,4 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Nav
@inject timetracker.Data.UserNotificationService UserNotificationService
@implements IDisposable
@using System.Security.Claims
<MudThemeProvider Theme="_theme" /> <MudThemeProvider Theme="_theme" />
<MudPopoverProvider /> <MudPopoverProvider />
@@ -34,26 +29,6 @@
@code { @code {
private bool _drawerOpen = true; private bool _drawerOpen = true;
protected override void OnInitialized()
{
UserNotificationService.OnUserDeleted += HandleUserDeleted;
}
private async Task HandleUserDeleted(int deletedUserId)
{
var state = await AuthStateProvider.GetAuthenticationStateAsync();
var idClaim = state.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (idClaim != null && int.TryParse(idClaim, out var myId) && myId == deletedUserId)
{
await InvokeAsync(() => Nav.NavigateTo("/auth/logout", forceLoad: true));
}
}
public void Dispose()
{
UserNotificationService.OnUserDeleted -= HandleUserDeleted;
}
private readonly MudTheme _theme = new() private readonly MudTheme _theme = new()
{ {
PaletteLight = new PaletteLight PaletteLight = new PaletteLight
+4 -30
View File
@@ -1,36 +1,10 @@
<div style="display:flex; flex-direction:column; height:100%;"> <MudNavMenu>
<MudNavMenu Style="flex:1"> <MudText Typo="Typo.h6" Class="px-4 mt-4 mb-2">Navigation</MudText>
<MudDivider Class="mb-2" /> <MudDivider Class="mb-2" />
<MudNavLink Href="" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.CalendarMonth">Wochenübersicht</MudNavLink> <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="month" Icon="@Icons.Material.Filled.CalendarViewMonth">Monatsübersicht</MudNavLink>
<MudNavLink Href="feiertage" Icon="@Icons.Material.Filled.Celebration">Feiertage</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="urlaub-maximizer" Icon="@Icons.Material.Filled.AutoAwesome">Urlaubs-Maximizer</MudNavLink>
<MudNavLink Href="settings" Icon="@Icons.Material.Filled.Settings">Einstellungen</MudNavLink> <MudNavLink Href="settings" Icon="@Icons.Material.Filled.Settings">Einstellungen</MudNavLink>
<MudDivider Class="mt-2 mb-2" /> </MudNavMenu>
<AuthorizeView Policy="AdminOnly">
<MudNavLink Href="admin/users" Icon="@Icons.Material.Filled.AdminPanelSettings" IconColor="Color.Error">
Benutzerverwaltung
</MudNavLink>
</AuthorizeView>
</MudNavMenu>
<AuthorizeView>
<Authorized>
<MudDivider />
<MudStack Row="true" AlignItems="AlignItems.Center" Class="px-4 py-2" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.AccountCircle" Color="Color.Primary" Size="Size.Small" />
<MudText Typo="Typo.body2" Style="font-weight:600">@context.User.Identity?.Name</MudText>
</MudStack>
<MudNavMenu>
<MudNavLink Href="/auth/logout" Icon="@Icons.Material.Filled.Logout">Abmelden</MudNavLink>
</MudNavMenu>
</Authorized>
</AuthorizeView>
<MudTooltip Text="Changelog anzeigen" Placement="Placement.Top">
<MudNavMenu>
<MudNavLink Href="/changelog" Icon="@Icons.Material.Filled.NewReleases"
Style="color: var(--mud-palette-text-disabled); font-size:0.75rem;">
Version 1.1
</MudNavLink>
</MudNavMenu>
</MudTooltip>
</div>
-176
View File
@@ -1,176 +0,0 @@
@page "/admin/users"
@rendermode InteractiveServer
@attribute [Authorize(Policy = "AdminOnly")]
@inject AuthService AuthService
@inject ISnackbar Snackbar
@inject AuthenticationStateProvider AuthStateProvider
@inject timetracker.Data.UserNotificationService UserNotificationService
@implements IDisposable
<PageTitle>Benutzerverwaltung 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 Benutzer…</MudText>
</MudStack>
}
else
{
<MudStack Spacing="4">
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, #B71C1C 0%, #7F0000 100%); color:white;">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudIcon Icon="@Icons.Material.Filled.AdminPanelSettings" Style="color:white; font-size:2rem" />
<MudStack Spacing="0">
<MudText Typo="Typo.h5" Style="color:white; font-weight:700">Benutzerverwaltung</MudText>
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
@_users.Count Benutzer registriert
</MudText>
</MudStack>
</MudStack>
</MudPaper>
@* ── Tabelle ── *@
<MudCard Elevation="3" Class="rounded-xl">
<MudCardContent Class="pa-0">
<MudTable Items="_users" Hover="true" Striped="true" Dense="false"
SortLabel="Sortieren">
<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 Style="text-align:right">Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudText Typo="Typo.body2" Color="Color.Secondary">@context.Id</MudText>
</MudTd>
<MudTd>
@if (_editUserId == context.Id)
{
<MudTextField @bind-Value="_editUsername"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Immediate="true"
Style="max-width:220px"
OnKeyDown="@(async e => { if (e.Key == "Enter") await SaveRename(context); })" />
}
else
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.AccountCircle"
Color="@(context.Username == "marc" ? Color.Error : Color.Default)"
Size="Size.Small" />
<MudText Style="@(context.Username == "marc" ? "font-weight:700" : "")">
@context.Username
</MudText>
@if (context.Username == "marc")
{
<MudChip T="string" Size="Size.Small" Color="Color.Error" Variant="Variant.Outlined">Admin</MudChip>
}
</MudStack>
}
</MudTd>
<MudTd Style="text-align:right">
@if (_editUserId == context.Id)
{
<MudIconButton Icon="@Icons.Material.Filled.Check"
Color="Color.Success"
Size="Size.Small"
OnClick="@(() => SaveRename(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Close"
Color="Color.Default"
Size="Size.Small"
OnClick="CancelEdit" />
}
else
{
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Color="Color.Primary"
Size="Size.Small"
OnClick="@(() => StartEdit(context))" />
@if (context.Username != "marc")
{
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline"
Color="Color.Error"
Size="Size.Small"
OnClick="@(() => DeleteUser(context))" />
}
}
</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Color="Color.Secondary" Class="pa-4">Keine Benutzer gefunden.</MudText>
</NoRecordsContent>
</MudTable>
</MudCardContent>
</MudCard>
</MudStack>
}
@code {
private List<User> _users = [];
private bool _loading = true;
private int? _editUserId;
private string _editUsername = "";
protected override async Task OnInitializedAsync()
{
var claim = (await AuthStateProvider.GetAuthenticationStateAsync())
.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return;
UserNotificationService.OnUsersChanged += RefreshUsers;
_users = await AuthService.GetAllUsersAsync();
_loading = false;
}
private async Task RefreshUsers()
{
_users = await AuthService.GetAllUsersAsync();
await InvokeAsync(StateHasChanged);
}
public void Dispose()
{
UserNotificationService.OnUsersChanged -= RefreshUsers;
}
private void StartEdit(User user)
{
_editUserId = user.Id;
_editUsername = user.Username;
}
private void CancelEdit()
{
_editUserId = null;
_editUsername = "";
}
private async Task SaveRename(User user)
{
var trimmed = _editUsername.Trim();
var error = await AuthService.RenameUserAsync(user.Id, trimmed);
if (error != null)
{
Snackbar.Add(error, Severity.Error);
return;
}
user.Username = trimmed;
_editUserId = null;
_editUsername = "";
Snackbar.Add($"Benutzer umbenannt zu \"{trimmed}\".", Severity.Success);
}
private async Task DeleteUser(User user)
{
await AuthService.DeleteUserAsync(user.Id);
_users.Remove(user);
Snackbar.Add($"Benutzer \"{user.Username}\" gelöscht.", Severity.Info);
}
}
-92
View File
@@ -1,92 +0,0 @@
@page "/changelog"
@rendermode InteractiveServer
@attribute [Authorize]
<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;">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudIcon Icon="@Icons.Material.Filled.NewReleases" Style="color:white; 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>
</MudStack>
</MudStack>
</MudPaper>
@foreach (var release in _releases)
{
<MudCard Elevation="2" Class="rounded-xl">
<MudCardContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-3">
<MudChip T="string" Color="@(release.IsLatest ? Color.Primary : Color.Default)"
Variant="Variant.Filled" Size="Size.Medium" Style="font-weight:700">
@release.Version
</MudChip>
@if (release.IsLatest)
{
<MudChip T="string" Color="Color.Success" Variant="Variant.Outlined" Size="Size.Small">
Aktuell
</MudChip>
}
<MudText Typo="Typo.body2" Color="Color.Secondary">@release.Date</MudText>
</MudStack>
<MudDivider Class="mb-3" />
<MudStack Spacing="1">
@foreach (var entry in release.Entries)
{
<MudStack Row="true" AlignItems="AlignItems.Start" Spacing="2">
<MudChip T="string" Color="@GetTagColor(entry.Tag)" Variant="Variant.Outlined"
Size="Size.Small" Style="min-width:72px; justify-content:center; font-size:0.7rem;">
@entry.Tag
</MudChip>
<MudText Typo="Typo.body2" Style="padding-top:2px">@entry.Text</MudText>
</MudStack>
}
</MudStack>
</MudCardContent>
</MudCard>
}
</MudStack>
@code {
private record ChangeEntry(string Tag, string Text);
private record Release(string Version, string Date, bool IsLatest, List<ChangeEntry> Entries);
private static Color GetTagColor(string tag) => tag switch
{
"Neu" => Color.Success,
"Fix" => Color.Error,
"Upgrade" => Color.Info,
_ => Color.Default
};
private readonly List<Release> _releases =
[
new("1.1", "08.06.2026", true,
[
new("Neu", "Versionsnummer in der Navbar mit Link zum Changelog"),
new("Neu", "Changelog-Seite"),
new("Neu", "Live-Aktualisierung der Benutzerliste bei neuer Registrierung"),
new("Neu", "Automatisches Abmelden gelöschter Benutzer"),
new("Neu", "Benutzernamen in der Benutzerverwaltung umbenennen"),
new("Upgrade", "Navbar: Benutzer und Abmelden-Button unten fixiert"),
]),
new("1.0", "20.05.2026", false,
[
new("Neu", "Erste Version des Timetrackers"),
new("Neu", "Wochenübersicht mit Arbeitszeiten und Pausen"),
new("Neu", "Monatsübersicht"),
new("Neu", "Feiertage-Verwaltung"),
new("Neu", "Urlaubs-Maximizer"),
new("Neu", "Einstellungen"),
new("Neu", "Benutzerverwaltung für Admins"),
new("Neu", "Registrierung und Login"),
]),
];
}
+1 -14
View File
@@ -1,9 +1,6 @@
@page "/feiertage" @page "/feiertage"
@rendermode InteractiveServer @rendermode InteractiveServer
@attribute [Authorize]
@inject HolidayService HolidayService @inject HolidayService HolidayService
@inject TimetrackerService TrackerService
@inject AuthenticationStateProvider AuthStateProvider
<PageTitle>Feiertage Timetracker</PageTitle> <PageTitle>Feiertage Timetracker</PageTitle>
@@ -188,25 +185,15 @@ else
private List<PublicHoliday> _holidays = []; private List<PublicHoliday> _holidays = [];
private string _subLabel = ""; private string _subLabel = "";
private int _userId;
private AppSettings _settings = new();
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var claim = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
if (claim != null)
{
_userId = int.Parse(claim.Value);
_settings = await TrackerService.GetSettingsAsync(_userId);
}
await LoadHolidays(); await LoadHolidays();
_loading = false; _loading = false;
} }
private async Task LoadHolidays() private async Task LoadHolidays()
{ {
_holidays = await HolidayService.GetHolidaysAsync(_year, _settings.GermanState); _holidays = await HolidayService.GetHolidaysAsync(_year);
var count = _holidays.Count; var count = _holidays.Count;
var past = _holidays.Count(h => h.Date < DateOnly.FromDateTime(DateTime.Today)); var past = _holidays.Count(h => h.Date < DateOnly.FromDateTime(DateTime.Today));
_subLabel = count == 0 _subLabel = count == 0
+8 -18
View File
@@ -1,10 +1,8 @@
@page "/" @page "/"
@rendermode InteractiveServer @rendermode InteractiveServer
@attribute [Authorize]
@inject TimetrackerService TrackerService @inject TimetrackerService TrackerService
@inject HolidayService HolidayService @inject HolidayService HolidayService
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject AuthenticationStateProvider AuthStateProvider
<PageTitle>KW @_kw Wochenübersicht Timetracker</PageTitle> <PageTitle>KW @_kw Wochenübersicht Timetracker</PageTitle>
@@ -334,7 +332,6 @@ else
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE"); private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
private bool _loading = true; private bool _loading = true;
private int _userId;
private DateOnly _monday; private DateOnly _monday;
private List<DayVm> _days = []; private List<DayVm> _days = [];
private AppSettings _settings = new(); private AppSettings _settings = new();
@@ -352,14 +349,10 @@ else
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return; // Prerender-Pass Circuit noch nicht authentifiziert
_userId = int.Parse(claim.Value);
_monday = GetMonday(DateOnly.FromDateTime(DateTime.Today)); _monday = GetMonday(DateOnly.FromDateTime(DateTime.Today));
_settings = await TrackerService.GetSettingsAsync(_userId); _settings = await TrackerService.GetSettingsAsync();
await LoadWeek(); await LoadWeek();
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings); _totalOvertime = await TrackerService.GetTotalOvertimeAsync(_settings);
_loading = false; _loading = false;
} }
@@ -367,15 +360,15 @@ else
{ {
if (_monday.Year != _holidayYear) if (_monday.Year != _holidayYear)
{ {
var list = await HolidayService.GetHolidaysAsync(_monday.Year, _settings.GermanState); var list = await HolidayService.GetHolidaysAsync(_monday.Year);
_holidays = list.ToDictionary(h => h.Date, h => h.Name); _holidays = list.ToDictionary(h => h.Date, h => h.Name);
_holidayYear = _monday.Year; _holidayYear = _monday.Year;
} }
var dbDays = await TrackerService.GetWeekAsync(_userId, _monday); var dbDays = await TrackerService.GetWeekAsync(_monday);
_days = Enumerable.Range(0, 7).Select(i => _days = Enumerable.Range(0, 7).Select(i =>
{ {
var date = _monday.AddDays(i); var date = _monday.AddDays(i);
return DayVm.From(dbDays.FirstOrDefault(d => d.Date == date), date, _userId); return DayVm.From(dbDays.FirstOrDefault(d => d.Date == date), date);
}).ToList(); }).ToList();
BuildWeekLabels(); BuildWeekLabels();
} }
@@ -428,7 +421,7 @@ else
private async Task SaveDay(DayVm day) private async Task SaveDay(DayVm day)
{ {
await TrackerService.UpsertWorkDayAsync(day.ToWorkDay()); await TrackerService.UpsertWorkDayAsync(day.ToWorkDay());
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings); _totalOvertime = await TrackerService.GetTotalOvertimeAsync(_settings);
BuildWeekLabels(); BuildWeekLabels();
} }
@@ -493,7 +486,6 @@ else
private sealed class DayVm private sealed class DayVm
{ {
public int Id { get; set; } public int Id { get; set; }
public int UserId { get; set; }
public DateOnly Date { get; set; } public DateOnly Date { get; set; }
public TimeSpan? Start { get; set; } public TimeSpan? Start { get; set; }
public TimeSpan? End { get; set; } public TimeSpan? End { get; set; }
@@ -508,10 +500,9 @@ else
public TimeSpan? NetWork => GrossWork.HasValue ? GrossWork.Value - TotalBreakTime : null; public TimeSpan? NetWork => GrossWork.HasValue ? GrossWork.Value - TotalBreakTime : null;
public static DayVm From(WorkDay? wd, DateOnly date, int userId) => new() public static DayVm From(WorkDay? wd, DateOnly date) => new()
{ {
Id = wd?.Id ?? 0, Id = wd?.Id ?? 0,
UserId = wd?.UserId ?? userId,
Date = date, Date = date,
Start = wd?.StartTime?.ToTimeSpan(), Start = wd?.StartTime?.ToTimeSpan(),
End = wd?.EndTime?.ToTimeSpan(), End = wd?.EndTime?.ToTimeSpan(),
@@ -526,7 +517,6 @@ else
public WorkDay ToWorkDay() => new() public WorkDay ToWorkDay() => new()
{ {
Id = Id, Id = Id,
UserId = UserId,
Date = Date, Date = Date,
StartTime = Start.HasValue ? TimeOnly.FromTimeSpan(Start.Value) : null, StartTime = Start.HasValue ? TimeOnly.FromTimeSpan(Start.Value) : null,
EndTime = End.HasValue ? TimeOnly.FromTimeSpan(End.Value) : null, EndTime = End.HasValue ? TimeOnly.FromTimeSpan(End.Value) : null,
-140
View File
@@ -1,140 +0,0 @@
@page "/login"
@attribute [AllowAnonymous]
@inject NavigationManager Nav
<PageTitle>Anmelden Timetracker</PageTitle>
<MudContainer MaxWidth="MaxWidth.Small" Class="mt-16">
<MudStack AlignItems="AlignItems.Center" Spacing="4">
@* ── Logo / Header ── *@
<MudStack AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.AccessTime"
Style="font-size:4rem; color:#1565C0" />
<MudText Typo="Typo.h4" Style="font-weight:700; color:#1565C0">Timetracker</MudText>
</MudStack>
<MudPaper Elevation="4" Class="pa-6 rounded-xl" Style="width:100%">
@* ── Static Tab Navigation ── *@
<MudStack Row="true" Justify="Justify.Center" Class="mb-4">
<MudButton Href="/login"
Variant="@(_activeTab == 0 ? Variant.Filled : Variant.Text)"
Color="Color.Primary"
Style="min-width: 120px; border-radius: 20px;">
Anmelden
</MudButton>
<MudButton Href="/login?tab=register"
Variant="@(_activeTab == 1 ? Variant.Filled : Variant.Text)"
Color="Color.Primary"
Style="min-width: 120px; border-radius: 20px;">
Registrieren
</MudButton>
</MudStack>
<MudDivider Class="mb-6" />
@if (_activeTab == 0)
{
@* ── Login Form ── *@
<MudStack Spacing="3">
@if (_error != null)
{
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
}
<form action="/auth/login" method="post">
<MudStack Spacing="3">
<MudTextField T="string"
Label="Benutzername"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Person"
name="username"
Required="true"
AutoFocus="true" />
<MudTextField T="string"
Label="Passwort"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Lock"
InputType="InputType.Password"
name="password"
Required="true" />
<MudButton ButtonType="ButtonType.Submit"
Variant="Variant.Filled"
Color="Color.Primary"
FullWidth="true"
Size="Size.Large"
StartIcon="@Icons.Material.Filled.Login"
Class="mt-2">
Anmelden
</MudButton>
</MudStack>
</form>
</MudStack>
}
else
{
@* ── Register Form ── *@
<MudStack Spacing="3">
@if (_error != null)
{
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
}
<form action="/auth/register" method="post">
<MudStack Spacing="3">
<MudTextField T="string"
Label="Benutzername"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Person"
name="username"
Required="true"
HelperText="Mindestens 3 Zeichen" />
<MudTextField T="string"
Label="Passwort"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Lock"
InputType="InputType.Password"
name="password"
Required="true"
HelperText="Mindestens 6 Zeichen" />
<MudButton ButtonType="ButtonType.Submit"
Variant="Variant.Filled"
Color="Color.Secondary"
FullWidth="true"
Size="Size.Large"
StartIcon="@Icons.Material.Filled.PersonAdd"
Class="mt-2">
Konto erstellen
</MudButton>
</MudStack>
</form>
</MudStack>
}
</MudPaper>
</MudStack>
</MudContainer>
@code {
private int _activeTab = 0;
private string? _error;
[SupplyParameterFromQuery(Name = "error")]
public string? ErrorParam { get; set; }
[SupplyParameterFromQuery(Name = "tab")]
public string? TabParam { get; set; }
protected override void OnParametersSet()
{
_error = ErrorParam switch
{
"invalid" => "Benutzername oder Passwort falsch.",
not null => Uri.UnescapeDataString(ErrorParam),
_ => null
};
_activeTab = TabParam == "register" ? 1 : 0;
}
}
+4 -11
View File
@@ -1,9 +1,7 @@
@page "/month" @page "/month"
@rendermode InteractiveServer @rendermode InteractiveServer
@attribute [Authorize]
@inject TimetrackerService TrackerService @inject TimetrackerService TrackerService
@inject HolidayService HolidayService @inject HolidayService HolidayService
@inject AuthenticationStateProvider AuthStateProvider
<PageTitle>@_deCulture.DateTimeFormat.GetMonthName(_month) @_year Monatsübersicht Timetracker</PageTitle> <PageTitle>@_deCulture.DateTimeFormat.GetMonthName(_month) @_year Monatsübersicht Timetracker</PageTitle>
@@ -152,7 +150,6 @@ else
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE"); private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
private bool _loading = true; private bool _loading = true;
private int _userId;
private int _year = DateTime.Today.Year; private int _year = DateTime.Today.Year;
private int _month = DateTime.Today.Month; private int _month = DateTime.Today.Month;
private List<MonthDayVm> _days = []; private List<MonthDayVm> _days = [];
@@ -170,20 +167,16 @@ else
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var authState = await AuthStateProvider.GetAuthenticationStateAsync(); _settings = await TrackerService.GetSettingsAsync();
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return;
_userId = int.Parse(claim.Value);
_settings = await TrackerService.GetSettingsAsync(_userId);
await LoadMonth(); await LoadMonth();
_loading = false; _loading = false;
} }
private async Task LoadMonth() private async Task LoadMonth()
{ {
var workDays = await TrackerService.GetMonthAsync(_userId, _year, _month); var workDays = await TrackerService.GetMonthAsync(_year, _month);
var holidays = await HolidayService.GetHolidaysAsync(_year, _settings.GermanState); var holidays = await HolidayService.GetHolidaysAsync(_year);
var vacations = await TrackerService.GetVacationDaysAsync(_userId, _year); var vacations = await TrackerService.GetVacationDaysAsync(_year);
var holidayMap = holidays.ToDictionary(h => h.Date, h => h.Name); var holidayMap = holidays.ToDictionary(h => h.Date, h => h.Name);
var vacationSet = vacations.Select(v => v.Date).ToHashSet(); var vacationSet = vacations.Select(v => v.Date).ToHashSet();
+9 -103
View File
@@ -1,10 +1,8 @@
@page "/settings" @page "/settings"
@rendermode InteractiveServer @rendermode InteractiveServer
@attribute [Authorize]
@inject TimetrackerService TrackerService @inject TimetrackerService TrackerService
@inject HolidayService HolidayService @inject HolidayService HolidayService
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject AuthenticationStateProvider AuthStateProvider
<PageTitle>Einstellungen Timetracker</PageTitle> <PageTitle>Einstellungen Timetracker</PageTitle>
@@ -128,65 +126,6 @@ else
</MudCard> </MudCard>
</MudItem> </MudItem>
@* ── Region & Feiertage ── *@
<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.Public" Color="Color.Primary" />
<MudText Typo="Typo.h6" Style="font-weight:600">Region & Feiertage</MudText>
</MudStack>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudStack Spacing="4">
<MudText Typo="Typo.body2" Color="Color.Secondary">
Wähle dein Bundesland aus, um Bundesland-spezifische Feiertage zu berücksichtigen.
</MudText>
<MudSelect T="string" Label="Bundesland" Variant="Variant.Outlined" @bind-Value="_settings.GermanState" Clearable="true" Placeholder="Nur bundesweite Feiertage">
@foreach (var state in GermanStates)
{
<MudSelectItem Value="@state.Key">@state.Value</MudSelectItem>
}
</MudSelect>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
@* ── Gleitzeitkonto ── *@
<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.AccountBalance" Color="Color.Primary" />
<MudText Typo="Typo.h6" Style="font-weight:600">Gleitzeitkonto-Start</MudText>
</MudStack>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudStack Spacing="4">
<MudDatePicker Label="Berechnungsstart"
@bind-Date="_flexStartDate"
HelperText="Gleitzeitberechnung läuft ab diesem Datum. Wenn leer, ab dem ersten Arbeitseintrag."
Variant="Variant.Outlined"
Clearable="true"
DateFormat="dd.MM.yyyy"
PickerVariant="PickerVariant.Inline" />
<MudNumericField @bind-Value="_settings.FlexTimeStartingBalanceHours"
Label="Anfangsüberstunden (h)"
Variant="Variant.Outlined"
Step="0.5"
Format="0.##"
HelperText="Stufensaldo (Guthaben/Schulden) zum Berechnungsstart" />
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
</MudGrid> </MudGrid>
@* ── Speichern-Button ── *@ @* ── Speichern-Button ── *@
@@ -436,33 +375,6 @@ else
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE"); private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
private AppSettings? _settings; private AppSettings? _settings;
private int _userId;
private DateTime? _flexStartDate
{
get => _settings?.FlexTimeStartDate?.ToDateTime(TimeOnly.MinValue);
set => _settings!.FlexTimeStartDate = value.HasValue ? DateOnly.FromDateTime(value.Value) : null;
}
private static readonly Dictionary<string, string> GermanStates = new()
{
{ "DE-BW", "Baden-Württemberg" },
{ "DE-BY", "Bayern" },
{ "DE-BE", "Berlin" },
{ "DE-BB", "Brandenburg" },
{ "DE-HB", "Bremen" },
{ "DE-HH", "Hamburg" },
{ "DE-HE", "Hessen" },
{ "DE-MV", "Mecklenburg-Vorpommern" },
{ "DE-NI", "Niedersachsen" },
{ "DE-NW", "Nordrhein-Westfalen" },
{ "DE-RP", "Rheinland-Pfalz" },
{ "DE-SL", "Saarland" },
{ "DE-SN", "Sachsen" },
{ "DE-ST", "Sachsen-Anhalt" },
{ "DE-SH", "Schleswig-Holstein" },
{ "DE-TH", "Thüringen" }
};
private int _vacYear = DateTime.Today.Year; private int _vacYear = DateTime.Today.Year;
private List<VacationDay> _vacationDays = []; private List<VacationDay> _vacationDays = [];
private DateTime? _newVacDateFrom; private DateTime? _newVacDateFrom;
@@ -490,18 +402,14 @@ else
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var authState = await AuthStateProvider.GetAuthenticationStateAsync(); _settings = await TrackerService.GetSettingsAsync();
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return;
_userId = int.Parse(claim.Value);
_settings = await TrackerService.GetSettingsAsync(_userId);
await LoadVacations(); await LoadVacations();
_holHolidays = await HolidayService.GetHolidaysAsync(_holYear, _settings.GermanState); _holHolidays = await HolidayService.GetHolidaysAsync(_holYear);
} }
private async Task LoadVacations() private async Task LoadVacations()
{ {
_vacationDays = await TrackerService.GetVacationDaysAsync(_userId, _vacYear); _vacationDays = await TrackerService.GetVacationDaysAsync(_vacYear);
} }
private async Task ChangeYear(int delta) private async Task ChangeYear(int delta)
@@ -514,8 +422,6 @@ else
{ {
if (_settings == null) return; if (_settings == null) return;
await TrackerService.SaveSettingsAsync(_settings); await TrackerService.SaveSettingsAsync(_settings);
// Nach dem Speichern Feiertage neu laden, falls sich das Bundesland geändert hat
_holHolidays = await HolidayService.GetHolidaysAsync(_holYear, _settings.GermanState);
Snackbar.Add("Einstellungen gespeichert", Severity.Success); Snackbar.Add("Einstellungen gespeichert", Severity.Success);
} }
@@ -532,7 +438,7 @@ else
{ {
if (_settings!.IsWorkDay(current.DayOfWeek)) if (_settings!.IsWorkDay(current.DayOfWeek))
{ {
await TrackerService.AddVacationDayAsync(new VacationDay { UserId = _userId, Date = current, Note = note }); await TrackerService.AddVacationDayAsync(new VacationDay { Date = current, Note = note });
added++; added++;
} }
current = current.AddDays(1); current = current.AddDays(1);
@@ -546,7 +452,7 @@ else
private async Task RemoveVacation(int id) private async Task RemoveVacation(int id)
{ {
await TrackerService.RemoveVacationDayAsync(_userId, id); await TrackerService.RemoveVacationDayAsync(id);
await LoadVacations(); await LoadVacations();
Snackbar.Add("Urlaubstag entfernt", Severity.Info); Snackbar.Add("Urlaubstag entfernt", Severity.Info);
} }
@@ -555,14 +461,14 @@ else
private async Task ChangeHolYear(int delta) private async Task ChangeHolYear(int delta)
{ {
_holYear += delta; _holYear += delta;
_holHolidays = await HolidayService.GetHolidaysAsync(_holYear, _settings?.GermanState); _holHolidays = await HolidayService.GetHolidaysAsync(_holYear);
} }
private async Task FetchHolidays() private async Task FetchHolidays()
{ {
_fetchingHolidays = true; _fetchingHolidays = true;
var (success, message) = await HolidayService.FetchAndStoreAsync(_holYear); var (success, message) = await HolidayService.FetchAndStoreAsync(_holYear);
_holHolidays = await HolidayService.GetHolidaysAsync(_holYear, _settings?.GermanState); _holHolidays = await HolidayService.GetHolidaysAsync(_holYear);
_fetchingHolidays = false; _fetchingHolidays = false;
Snackbar.Add(message, success ? Severity.Success : Severity.Error); Snackbar.Add(message, success ? Severity.Success : Severity.Error);
} }
@@ -570,7 +476,7 @@ else
private async Task DeleteHoliday(int id) private async Task DeleteHoliday(int id)
{ {
await HolidayService.DeleteAsync(id); await HolidayService.DeleteAsync(id);
_holHolidays = await HolidayService.GetHolidaysAsync(_holYear, _settings?.GermanState); _holHolidays = await HolidayService.GetHolidaysAsync(_holYear);
Snackbar.Add("Feiertag entfernt", Severity.Info); Snackbar.Add("Feiertag entfernt", Severity.Info);
} }
+4 -11
View File
@@ -1,10 +1,8 @@
@page "/urlaub-maximizer" @page "/urlaub-maximizer"
@rendermode InteractiveServer @rendermode InteractiveServer
@attribute [Authorize]
@inject TimetrackerService TrackerService @inject TimetrackerService TrackerService
@inject HolidayService HolidayService @inject HolidayService HolidayService
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject AuthenticationStateProvider AuthStateProvider
<PageTitle>Urlaubs-Maximizer Timetracker</PageTitle> <PageTitle>Urlaubs-Maximizer Timetracker</PageTitle>
@@ -234,23 +232,18 @@ else
private int _remainingDays; private int _remainingDays;
private List<Suggestion> _suggestions = []; private List<Suggestion> _suggestions = [];
private string _subLabel = ""; private string _subLabel = "";
private int _userId;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var authState = await AuthStateProvider.GetAuthenticationStateAsync(); _settings = await TrackerService.GetSettingsAsync();
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return;
_userId = int.Parse(claim.Value);
_settings = await TrackerService.GetSettingsAsync(_userId);
await LoadYear(); await LoadYear();
_loading = false; _loading = false;
} }
private async Task LoadYear() private async Task LoadYear()
{ {
var holidays = await HolidayService.GetHolidaysAsync(_year, _settings.GermanState); var holidays = await HolidayService.GetHolidaysAsync(_year);
var vacations = await TrackerService.GetVacationDaysAsync(_userId, _year); var vacations = await TrackerService.GetVacationDaysAsync(_year);
_holidays = holidays.ToDictionary(h => h.Date, h => h.Name); _holidays = holidays.ToDictionary(h => h.Date, h => h.Name);
_vacationSet = vacations.Select(v => v.Date).ToHashSet(); _vacationSet = vacations.Select(v => v.Date).ToHashSet();
_remainingDays = Math.Max(0, _settings.VacationDaysPerYear - vacations.Count); _remainingDays = Math.Max(0, _settings.VacationDaysPerYear - vacations.Count);
@@ -269,7 +262,7 @@ else
private async Task TakeSuggestion(Suggestion s) private async Task TakeSuggestion(Suggestion s)
{ {
foreach (var d in s.VacationDaysToTake.Where(d => !_vacationSet.Contains(d))) foreach (var d in s.VacationDaysToTake.Where(d => !_vacationSet.Contains(d)))
await TrackerService.AddVacationDayAsync(new VacationDay { UserId = _userId, Date = d, Note = "Urlaubs-Maximizer" }); await TrackerService.AddVacationDayAsync(new VacationDay { Date = d, Note = "Urlaubs-Maximizer" });
await LoadYear(); await LoadYear();
var word = s.VacationDaysNeeded == 1 ? "Urlaubstag" : "Urlaubstage"; var word = s.VacationDaysNeeded == 1 ? "Urlaubstag" : "Urlaubstage";
Snackbar.Add($"{s.VacationDaysNeeded} {word} eingetragen {s.TotalFreeDays} Tage frei!", Severity.Success); Snackbar.Add($"{s.VacationDaysNeeded} {word} eingetragen {s.TotalFreeDays} Tage frei!", Severity.Success);
-9
View File
@@ -1,9 +0,0 @@
@inject NavigationManager Nav
@code {
protected override void OnInitialized()
{
var returnUrl = Uri.EscapeDataString(Nav.Uri);
Nav.NavigateTo($"/login?returnUrl={returnUrl}", forceLoad: true);
}
}
+1 -5
View File
@@ -2,11 +2,7 @@
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)"> <Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData"> <Found Context="routeData">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)"> <RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<NotAuthorized>
<RedirectToLogin />
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="routeData" Selector="h1" /> <FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found> </Found>
</Router> </Router>
-3
View File
@@ -1,8 +1,5 @@
@using System.Net.Http @using System.Net.Http
@using System.Net.Http.Json @using System.Net.Http.Json
@using System.Security.Claims
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Web
-4
View File
@@ -3,13 +3,9 @@ namespace timetracker.Data;
public class AppSettings public class AppSettings
{ {
public int Id { get; set; } public int Id { get; set; }
public int UserId { get; set; }
public double DailyTargetHours { get; set; } = 7.5; public double DailyTargetHours { get; set; } = 7.5;
public int MinimumBreakMinutes { get; set; } = 30; public int MinimumBreakMinutes { get; set; } = 30;
public int VacationDaysPerYear { get; set; } = 30; public int VacationDaysPerYear { get; set; } = 30;
public string? GermanState { get; set; }
public DateOnly? FlexTimeStartDate { get; set; }
public double FlexTimeStartingBalanceHours { get; set; } = 0.0;
// Arbeitstage // Arbeitstage
public bool WorkMonday { get; set; } = true; public bool WorkMonday { get; set; } = true;
-96
View File
@@ -1,96 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
namespace timetracker.Data;
public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNotificationService notifier)
{
public async Task<User?> LoginAsync(string username, string password)
{
await using var db = await factory.CreateDbContextAsync();
var user = await db.Users
.FirstOrDefaultAsync(u => u.Username == username);
if (user == null) return null;
return VerifyPassword(password, user.PasswordHash, user.PasswordSalt) ? user : null;
}
public async Task<List<User>> GetAllUsersAsync()
{
await using var db = await factory.CreateDbContextAsync();
return await db.Users
.OrderBy(u => u.Username)
.ToListAsync();
}
public async Task DeleteUserAsync(int userId)
{
await using var db = await factory.CreateDbContextAsync();
var user = await db.Users.FindAsync(userId);
if (user != null)
{
db.Users.Remove(user);
await db.SaveChangesAsync();
await notifier.NotifyUserDeletedAsync(userId);
await notifier.NotifyUsersChangedAsync();
}
}
public async Task<string?> RenameUserAsync(int userId, string newUsername)
{
if (string.IsNullOrWhiteSpace(newUsername) || newUsername.Length < 3)
return "Benutzername muss mindestens 3 Zeichen lang sein.";
await using var db = await factory.CreateDbContextAsync();
if (await db.Users.AnyAsync(u => u.Username == newUsername && u.Id != userId))
return "Benutzername bereits vergeben.";
var user = await db.Users.FindAsync(userId);
if (user == null) return "Benutzer nicht gefunden.";
user.Username = newUsername;
await db.SaveChangesAsync();
await notifier.NotifyUsersChangedAsync();
return null;
}
public async Task<(User? User, string? Error)> RegisterAsync(string username, string password) {
if (string.IsNullOrWhiteSpace(username) || username.Length < 3)
return (null, "Benutzername muss mindestens 3 Zeichen lang sein.");
if (string.IsNullOrWhiteSpace(password) || password.Length < 6)
return (null, "Passwort muss mindestens 6 Zeichen lang sein.");
await using var db = await factory.CreateDbContextAsync();
if (await db.Users.AnyAsync(u => u.Username == username))
return (null, "Benutzername bereits vergeben.");
var (hash, salt) = HashPassword(password);
var user = new User { Username = username, PasswordHash = hash, PasswordSalt = salt };
db.Users.Add(user);
await db.SaveChangesAsync();
await notifier.NotifyUsersChangedAsync();
return (user, null);
}
private static (string hash, string salt) HashPassword(string password)
{
var saltBytes = RandomNumberGenerator.GetBytes(32);
var salt = Convert.ToBase64String(saltBytes);
var hash = ComputeHash(password, salt);
return (hash, salt);
}
private static bool VerifyPassword(string password, string hash, string salt)
=> ComputeHash(password, salt) == hash;
private static string ComputeHash(string password, string salt)
{
var hash = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(password),
Convert.FromBase64String(salt),
iterations: 200_000,
HashAlgorithmName.SHA256,
outputLength: 32);
return Convert.ToBase64String(hash);
}
}
+3 -17
View File
@@ -8,22 +8,13 @@ public class HolidayService(IDbContextFactory<TimetrackerDbContext> factory, Htt
{ {
private const string ApiUrl = "https://date.nager.at/api/v3/PublicHolidays/{0}/DE"; private const string ApiUrl = "https://date.nager.at/api/v3/PublicHolidays/{0}/DE";
public async Task<List<PublicHoliday>> GetHolidaysAsync(int year, string? stateCode = null) public async Task<List<PublicHoliday>> GetHolidaysAsync(int year)
{ {
await using var db = await factory.CreateDbContextAsync(); await using var db = await factory.CreateDbContextAsync();
var holidays = await db.PublicHolidays return await db.PublicHolidays
.Where(h => h.Date.Year == year) .Where(h => h.Date.Year == year)
.OrderBy(h => h.Date) .OrderBy(h => h.Date)
.ToListAsync(); .ToListAsync();
if (string.IsNullOrEmpty(stateCode))
{
// Default: return only global holidays (where Counties is null or empty)
return holidays.Where(h => string.IsNullOrEmpty(h.Counties)).ToList();
}
// Return global holidays OR holidays that match the user's state code
return holidays.Where(h => string.IsNullOrEmpty(h.Counties) || h.Counties.Split(',').Contains(stateCode)).ToList();
} }
public async Task<(bool Success, string Message)> FetchAndStoreAsync(int year) public async Task<(bool Success, string Message)> FetchAndStoreAsync(int year)
@@ -43,8 +34,7 @@ public class HolidayService(IDbContextFactory<TimetrackerDbContext> factory, Htt
.Select(h => new PublicHoliday .Select(h => new PublicHoliday
{ {
Date = DateOnly.Parse(h.Date), Date = DateOnly.Parse(h.Date),
Name = h.LocalName, Name = h.LocalName
Counties = h.Counties != null && h.Counties.Count > 0 ? string.Join(",", h.Counties) : null
})); }));
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -74,9 +64,5 @@ public class HolidayService(IDbContextFactory<TimetrackerDbContext> factory, Htt
[JsonPropertyName("localName")] [JsonPropertyName("localName")]
public string LocalName { get; set; } = ""; public string LocalName { get; set; } = "";
[JsonPropertyName("counties")]
public List<string>? Counties { get; set; }
} }
} }
-191
View File
@@ -1,191 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using timetracker.Data;
#nullable disable
namespace timetracker.Data.Migrations
{
[DbContext(typeof(TimetrackerDbContext))]
[Migration("20260522081459_AddMultiUser")]
partial class AddMultiUser
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
modelBuilder.Entity("timetracker.Data.AppSettings", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<double>("DailyTargetHours")
.HasColumnType("REAL");
b.Property<int>("MinimumBreakMinutes")
.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.Data.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.Data.PublicHoliday", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("PublicHolidays");
});
modelBuilder.Entity("timetracker.Data.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<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("timetracker.Data.VacationDay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("VacationDays");
});
modelBuilder.Entity("timetracker.Data.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>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("WorkDays");
});
modelBuilder.Entity("timetracker.Data.BreakEntry", b =>
{
b.HasOne("timetracker.Data.WorkDay", "WorkDay")
.WithMany("Breaks")
.HasForeignKey("WorkDayId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("WorkDay");
});
modelBuilder.Entity("timetracker.Data.WorkDay", b =>
{
b.Navigation("Breaks");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,69 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace timetracker.Data.Migrations
{
/// <inheritdoc />
public partial class AddMultiUser : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "UserId",
table: "WorkDays",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "UserId",
table: "VacationDays",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "UserId",
table: "AppSettings",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Username = table.Column<string>(type: "TEXT", nullable: false),
PasswordHash = table.Column<string>(type: "TEXT", nullable: false),
PasswordSalt = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Users");
migrationBuilder.DropColumn(
name: "UserId",
table: "WorkDays");
migrationBuilder.DropColumn(
name: "UserId",
table: "VacationDays");
migrationBuilder.DropColumn(
name: "UserId",
table: "AppSettings");
}
}
}
@@ -1,203 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using timetracker.Data;
#nullable disable
namespace timetracker.Data.Migrations
{
[DbContext(typeof(TimetrackerDbContext))]
[Migration("20260607213215_AddFlexTimeAndHolidayState")]
partial class AddFlexTimeAndHolidayState
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
modelBuilder.Entity("timetracker.Data.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>("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.Data.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.Data.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.Data.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<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("timetracker.Data.VacationDay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("VacationDays");
});
modelBuilder.Entity("timetracker.Data.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>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("WorkDays");
});
modelBuilder.Entity("timetracker.Data.BreakEntry", b =>
{
b.HasOne("timetracker.Data.WorkDay", "WorkDay")
.WithMany("Breaks")
.HasForeignKey("WorkDayId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("WorkDay");
});
modelBuilder.Entity("timetracker.Data.WorkDay", b =>
{
b.Navigation("Breaks");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,60 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace timetracker.Data.Migrations
{
/// <inheritdoc />
public partial class AddFlexTimeAndHolidayState : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Counties",
table: "PublicHolidays",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<DateOnly>(
name: "FlexTimeStartDate",
table: "AppSettings",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "FlexTimeStartingBalanceHours",
table: "AppSettings",
type: "REAL",
nullable: false,
defaultValue: 0.0);
migrationBuilder.AddColumn<string>(
name: "GermanState",
table: "AppSettings",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Counties",
table: "PublicHolidays");
migrationBuilder.DropColumn(
name: "FlexTimeStartDate",
table: "AppSettings");
migrationBuilder.DropColumn(
name: "FlexTimeStartingBalanceHours",
table: "AppSettings");
migrationBuilder.DropColumn(
name: "GermanState",
table: "AppSettings");
}
}
}
@@ -26,21 +26,9 @@ namespace timetracker.Data.Migrations
b.Property<double>("DailyTargetHours") b.Property<double>("DailyTargetHours")
.HasColumnType("REAL"); .HasColumnType("REAL");
b.Property<DateOnly?>("FlexTimeStartDate")
.HasColumnType("TEXT");
b.Property<double>("FlexTimeStartingBalanceHours")
.HasColumnType("REAL");
b.Property<string>("GermanState")
.HasColumnType("TEXT");
b.Property<int>("MinimumBreakMinutes") b.Property<int>("MinimumBreakMinutes")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<int>("VacationDaysPerYear") b.Property<int>("VacationDaysPerYear")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@@ -70,6 +58,24 @@ namespace timetracker.Data.Migrations
b.ToTable("AppSettings"); b.ToTable("AppSettings");
}); });
modelBuilder.Entity("timetracker.Data.PublicHoliday", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("PublicHolidays");
});
modelBuilder.Entity("timetracker.Data.BreakEntry", b => modelBuilder.Entity("timetracker.Data.BreakEntry", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -92,50 +98,6 @@ namespace timetracker.Data.Migrations
b.ToTable("BreakEntries"); b.ToTable("BreakEntries");
}); });
modelBuilder.Entity("timetracker.Data.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.Data.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<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("timetracker.Data.VacationDay", b => modelBuilder.Entity("timetracker.Data.VacationDay", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -148,9 +110,6 @@ namespace timetracker.Data.Migrations
b.Property<string>("Note") b.Property<string>("Note")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("VacationDays"); b.ToTable("VacationDays");
@@ -171,9 +130,6 @@ namespace timetracker.Data.Migrations
b.Property<TimeOnly?>("StartTime") b.Property<TimeOnly?>("StartTime")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("WorkDays"); b.ToTable("WorkDays");
-1
View File
@@ -5,5 +5,4 @@ public class PublicHoliday
public int Id { get; set; } public int Id { get; set; }
public DateOnly Date { get; set; } public DateOnly Date { get; set; }
public string Name { get; set; } = ""; public string Name { get; set; } = "";
public string? Counties { get; set; }
} }
-1
View File
@@ -4,7 +4,6 @@ namespace timetracker.Data;
public class TimetrackerDbContext(DbContextOptions<TimetrackerDbContext> options) : DbContext(options) public class TimetrackerDbContext(DbContextOptions<TimetrackerDbContext> options) : DbContext(options)
{ {
public DbSet<User> Users => Set<User>();
public DbSet<WorkDay> WorkDays => Set<WorkDay>(); public DbSet<WorkDay> WorkDays => Set<WorkDay>();
public DbSet<BreakEntry> BreakEntries => Set<BreakEntry>(); public DbSet<BreakEntry> BreakEntries => Set<BreakEntry>();
public DbSet<AppSettings> AppSettings => Set<AppSettings>(); public DbSet<AppSettings> AppSettings => Set<AppSettings>();
+24 -76
View File
@@ -4,12 +4,12 @@ namespace timetracker.Data;
public class TimetrackerService(IDbContextFactory<TimetrackerDbContext> factory) public class TimetrackerService(IDbContextFactory<TimetrackerDbContext> factory)
{ {
public async Task<List<WorkDay>> GetWeekAsync(int userId, DateOnly monday) public async Task<List<WorkDay>> GetWeekAsync(DateOnly monday)
{ {
await using var db = await factory.CreateDbContextAsync(); await using var db = await factory.CreateDbContextAsync();
return await db.WorkDays return await db.WorkDays
.Include(w => w.Breaks) .Include(w => w.Breaks)
.Where(w => w.UserId == userId && w.Date >= monday && w.Date < monday.AddDays(7)) .Where(w => w.Date >= monday && w.Date < monday.AddDays(7))
.OrderBy(w => w.Date) .OrderBy(w => w.Date)
.ToListAsync(); .ToListAsync();
} }
@@ -19,7 +19,7 @@ public class TimetrackerService(IDbContextFactory<TimetrackerDbContext> factory)
await using var db = await factory.CreateDbContextAsync(); await using var db = await factory.CreateDbContextAsync();
var existing = await db.WorkDays var existing = await db.WorkDays
.Include(w => w.Breaks) .Include(w => w.Breaks)
.FirstOrDefaultAsync(w => w.UserId == workDay.UserId && w.Date == workDay.Date); .FirstOrDefaultAsync(w => w.Date == workDay.Date);
if (existing == null) if (existing == null)
{ {
@@ -45,17 +45,17 @@ public class TimetrackerService(IDbContextFactory<TimetrackerDbContext> factory)
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
public async Task<AppSettings> GetSettingsAsync(int userId) public async Task<AppSettings> GetSettingsAsync()
{ {
await using var db = await factory.CreateDbContextAsync(); await using var db = await factory.CreateDbContextAsync();
return await db.AppSettings.FirstOrDefaultAsync(s => s.UserId == userId) return await db.AppSettings.FindAsync(1) ?? new AppSettings { Id = 1 };
?? new AppSettings { UserId = userId };
} }
public async Task SaveSettingsAsync(AppSettings settings) public async Task SaveSettingsAsync(AppSettings settings)
{ {
await using var db = await factory.CreateDbContextAsync(); await using var db = await factory.CreateDbContextAsync();
var existing = await db.AppSettings.FirstOrDefaultAsync(s => s.UserId == settings.UserId); settings.Id = 1;
var existing = await db.AppSettings.FindAsync(1);
if (existing == null) if (existing == null)
db.AppSettings.Add(settings); db.AppSettings.Add(settings);
else else
@@ -75,11 +75,11 @@ public class TimetrackerService(IDbContextFactory<TimetrackerDbContext> factory)
} }
// ── Urlaub ──────────────────────────────────────────────────────────── // ── Urlaub ────────────────────────────────────────────────────────────
public async Task<List<VacationDay>> GetVacationDaysAsync(int userId, int year) public async Task<List<VacationDay>> GetVacationDaysAsync(int year)
{ {
await using var db = await factory.CreateDbContextAsync(); await using var db = await factory.CreateDbContextAsync();
return await db.VacationDays return await db.VacationDays
.Where(v => v.UserId == userId && v.Date.Year == year) .Where(v => v.Date.Year == year)
.OrderBy(v => v.Date) .OrderBy(v => v.Date)
.ToListAsync(); .ToListAsync();
} }
@@ -87,7 +87,7 @@ public class TimetrackerService(IDbContextFactory<TimetrackerDbContext> factory)
public async Task AddVacationDayAsync(VacationDay vacationDay) public async Task AddVacationDayAsync(VacationDay vacationDay)
{ {
await using var db = await factory.CreateDbContextAsync(); await using var db = await factory.CreateDbContextAsync();
var exists = await db.VacationDays.AnyAsync(v => v.UserId == vacationDay.UserId && v.Date == vacationDay.Date); var exists = await db.VacationDays.AnyAsync(v => v.Date == vacationDay.Date);
if (!exists) if (!exists)
{ {
vacationDay.Id = 0; vacationDay.Id = 0;
@@ -96,10 +96,10 @@ public class TimetrackerService(IDbContextFactory<TimetrackerDbContext> factory)
} }
} }
public async Task RemoveVacationDayAsync(int userId, int id) public async Task RemoveVacationDayAsync(int id)
{ {
await using var db = await factory.CreateDbContextAsync(); await using var db = await factory.CreateDbContextAsync();
var v = await db.VacationDays.FirstOrDefaultAsync(v => v.Id == id && v.UserId == userId); var v = await db.VacationDays.FindAsync(id);
if (v != null) if (v != null)
{ {
db.VacationDays.Remove(v); db.VacationDays.Remove(v);
@@ -108,90 +108,38 @@ public class TimetrackerService(IDbContextFactory<TimetrackerDbContext> factory)
} }
// ── Gleitzeitkonto ─────────────────────────────────────────────────── // ── Gleitzeitkonto ───────────────────────────────────────────────────
public async Task<TimeSpan> GetTotalOvertimeAsync(int userId, AppSettings settings) public async Task<TimeSpan> GetTotalOvertimeAsync(AppSettings settings)
{ {
await using var db = await factory.CreateDbContextAsync(); await using var db = await factory.CreateDbContextAsync();
var allDays = await db.WorkDays
// 1. Finde das Startdatum für die Berechnung
var firstDay = await db.WorkDays
.Where(w => w.UserId == userId)
.OrderBy(w => w.Date)
.Select(w => (DateOnly?)w.Date)
.FirstOrDefaultAsync();
if (firstDay == null && !settings.FlexTimeStartDate.HasValue)
return TimeSpan.FromHours(settings.FlexTimeStartingBalanceHours);
var startDate = settings.FlexTimeStartDate ?? firstDay!.Value;
var endDate = DateOnly.FromDateTime(DateTime.Today);
if (startDate > endDate)
return TimeSpan.FromHours(settings.FlexTimeStartingBalanceHours);
// 2. Lade alle erfassten Arbeitstage in diesem Zeitraum
var workDays = await db.WorkDays
.Include(w => w.Breaks) .Include(w => w.Breaks)
.Where(w => w.UserId == userId && w.Date >= startDate && w.Date <= endDate) .Where(w => w.StartTime != null && w.EndTime != null)
.ToDictionaryAsync(w => w.Date);
// 3. Lade alle Feiertage in diesem Zeitraum
var holidaysList = await db.PublicHolidays
.Where(h => h.Date >= startDate && h.Date <= endDate)
.ToListAsync(); .ToListAsync();
var state = settings.GermanState; var total = TimeSpan.Zero;
var holidaySet = holidaysList foreach (var wd in allDays)
.Where(h => string.IsNullOrEmpty(h.Counties) || (!string.IsNullOrEmpty(state) && h.Counties.Split(',').Contains(state)))
.Select(h => h.Date)
.ToHashSet();
// 4. Lade alle Urlaubstage in diesem Zeitraum
var vacationSet = await db.VacationDays
.Where(v => v.UserId == userId && v.Date >= startDate && v.Date <= endDate)
.Select(v => v.Date)
.ToHashSetAsync();
double totalOvertimeHours = settings.FlexTimeStartingBalanceHours;
for (var date = startDate; date <= endDate; date = date.AddDays(1))
{
bool isWorkDay = settings.IsWorkDay(date.DayOfWeek);
bool isHoliday = holidaySet.Contains(date);
bool isVacation = vacationSet.Contains(date);
// Sollzeit gilt nur an regulären Arbeitstagen, die weder Feiertag noch Urlaub sind
double target = (isWorkDay && !isHoliday && !isVacation) ? settings.DailyTargetHours : 0.0;
double actual = 0.0;
if (workDays.TryGetValue(date, out var wd) && wd.StartTime != null && wd.EndTime != null)
{
var gross = wd.EndTime.Value.ToTimeSpan() - wd.StartTime.Value.ToTimeSpan();
if (gross > TimeSpan.Zero)
{ {
if (!settings.IsWorkDay(wd.Date.DayOfWeek)) continue;
var gross = wd.EndTime!.Value.ToTimeSpan() - wd.StartTime!.Value.ToTimeSpan();
if (gross <= TimeSpan.Zero) continue;
var breakTotal = wd.Breaks var breakTotal = wd.Breaks
.Where(b => b.StartTime.HasValue && b.EndTime.HasValue && b.EndTime > b.StartTime) .Where(b => b.StartTime.HasValue && b.EndTime.HasValue && b.EndTime > b.StartTime)
.Aggregate(TimeSpan.Zero, (s, b) => .Aggregate(TimeSpan.Zero, (s, b) =>
s + (b.EndTime!.Value.ToTimeSpan() - b.StartTime!.Value.ToTimeSpan())); s + (b.EndTime!.Value.ToTimeSpan() - b.StartTime!.Value.ToTimeSpan()));
actual = (gross - breakTotal).TotalHours; total += gross - breakTotal - TimeSpan.FromHours(settings.DailyTargetHours);
if (actual < 0) actual = 0.0;
} }
} return total;
totalOvertimeHours += (actual - target);
}
return TimeSpan.FromHours(totalOvertimeHours);
} }
// ── Monatsübersicht ─────────────────────────────────────────────────── // ── Monatsübersicht ───────────────────────────────────────────────────
public async Task<List<WorkDay>> GetMonthAsync(int userId, int year, int month) public async Task<List<WorkDay>> GetMonthAsync(int year, int month)
{ {
await using var db = await factory.CreateDbContextAsync(); await using var db = await factory.CreateDbContextAsync();
var from = new DateOnly(year, month, 1); var from = new DateOnly(year, month, 1);
var to = from.AddMonths(1); var to = from.AddMonths(1);
return await db.WorkDays return await db.WorkDays
.Include(w => w.Breaks) .Include(w => w.Breaks)
.Where(w => w.UserId == userId && w.Date >= from && w.Date < to) .Where(w => w.Date >= from && w.Date < to)
.OrderBy(w => w.Date) .OrderBy(w => w.Date)
.ToListAsync(); .ToListAsync();
} }
-9
View File
@@ -1,9 +0,0 @@
namespace timetracker.Data;
public class User
{
public int Id { get; set; }
public string Username { get; set; } = "";
public string PasswordHash { get; set; } = "";
public string PasswordSalt { get; set; } = "";
}
-19
View File
@@ -1,19 +0,0 @@
namespace timetracker.Data;
public class UserNotificationService
{
public event Func<Task>? OnUsersChanged;
public event Func<int, Task>? OnUserDeleted;
public async Task NotifyUsersChangedAsync()
{
if (OnUsersChanged != null)
await OnUsersChanged.Invoke();
}
public async Task NotifyUserDeletedAsync(int userId)
{
if (OnUserDeleted != null)
await OnUserDeleted.Invoke(userId);
}
}
-1
View File
@@ -3,7 +3,6 @@ namespace timetracker.Data;
public class VacationDay public class VacationDay
{ {
public int Id { get; set; } public int Id { get; set; }
public int UserId { get; set; }
public DateOnly Date { get; set; } public DateOnly Date { get; set; }
public string? Note { get; set; } public string? Note { get; set; }
} }
-1
View File
@@ -3,7 +3,6 @@ namespace timetracker.Data;
public class WorkDay public class WorkDay
{ {
public int Id { get; set; } public int Id { get; set; }
public int UserId { get; set; }
public DateOnly Date { get; set; } public DateOnly Date { get; set; }
public TimeOnly? StartTime { get; set; } public TimeOnly? StartTime { get; set; }
public TimeOnly? EndTime { get; set; } public TimeOnly? EndTime { get; set; }
+1 -2
View File
@@ -7,7 +7,7 @@ COPY timetracker.csproj ./
RUN dotnet restore timetracker.csproj RUN dotnet restore timetracker.csproj
COPY . ./ COPY . ./
RUN dotnet publish -c Release -o /app/publish RUN dotnet publish -c Release -o /app/publish --no-restore
# ── Runtime Stage ───────────────────────────────────────────────────────────── # ── Runtime Stage ─────────────────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
@@ -19,7 +19,6 @@ RUN mkdir -p /data
ENV ASPNETCORE_HTTP_PORTS=8080 ENV ASPNETCORE_HTTP_PORTS=8080
ENV ASPNETCORE_ENVIRONMENT=Production ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
ENV TIMETRACKER_DB_PATH=/data/timetracker.db ENV TIMETRACKER_DB_PATH=/data/timetracker.db
ENV EnableHttpsRedirect=false ENV EnableHttpsRedirect=false
-84
View File
@@ -1,7 +1,3 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MudBlazor.Services; using MudBlazor.Services;
using timetracker.Components; using timetracker.Components;
@@ -9,24 +5,6 @@ using timetracker.Data;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/login";
options.LogoutPath = "/auth/logout";
options.ExpireTimeSpan = TimeSpan.FromDays(30);
options.SlidingExpiration = true;
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy =>
policy.RequireClaim(System.Security.Claims.ClaimTypes.Name, "marc"));
});
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<timetracker.Data.UserNotificationService>();
builder.Services.AddScoped<AuthService>();
// Add services to the container. // Add services to the container.
builder.Services.AddRazorComponents() builder.Services.AddRazorComponents()
.AddInteractiveServerComponents(); .AddInteractiveServerComponents();
@@ -48,14 +26,6 @@ using (var scope = app.Services.CreateScope())
await db.Database.MigrateAsync(); await db.Database.MigrateAsync();
} }
var forwardedHeadersOptions = new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
};
forwardedHeadersOptions.KnownProxies.Clear();
forwardedHeadersOptions.KnownIPNetworks.Clear();
app.UseForwardedHeaders(forwardedHeadersOptions);
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment()) if (!app.Environment.IsDevelopment())
{ {
@@ -69,64 +39,10 @@ if (app.Configuration.GetValue("EnableHttpsRedirect", !app.Environment.IsDevelop
app.UseHttpsRedirection(); app.UseHttpsRedirection();
} }
// Statische Dateien (inkl. _framework/, _content/) vor Auth bedienen,
// damit Blazor-JS und MudBlazor-CSS nie durch Auth-Middleware geblockt werden
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery(); app.UseAntiforgery();
app.MapStaticAssets(); app.MapStaticAssets();
app.MapRazorComponents<App>() app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode(); .AddInteractiveServerRenderMode();
// ── Auth-Endpoints ────────────────────────────────────────────────────────────
app.MapPost("/auth/login", async (HttpContext ctx, AuthService authService) =>
{
var form = await ctx.Request.ReadFormAsync();
var username = form["username"].ToString();
var password = form["password"].ToString();
var user = await authService.LoginAsync(username, password);
if (user == null)
return Results.Redirect("/login?error=invalid");
var claims = new[] {
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username)
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity),
new AuthenticationProperties { IsPersistent = true });
return Results.Redirect("/");
}).DisableAntiforgery();
app.MapPost("/auth/register", async (HttpContext ctx, AuthService authService) =>
{
var form = await ctx.Request.ReadFormAsync();
var username = form["username"].ToString();
var password = form["password"].ToString();
var (user, error) = await authService.RegisterAsync(username, password);
if (user == null)
return Results.Redirect($"/login?tab=register&error={Uri.EscapeDataString(error!)}");
var claims = new[] {
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username)
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity),
new AuthenticationProperties { IsPersistent = true });
return Results.Redirect("/");
}).DisableAntiforgery();
app.MapGet("/auth/logout", async (HttpContext ctx) =>
{
await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Results.Redirect("/login");
});
app.Run(); app.Run();
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
</startup>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="Microsoft.Build" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-15.1.0.0" newVersion="15.1.0.0" />
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="Microsoft.Build.Framework" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-15.1.0.0" newVersion="15.1.0.0" />
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="Microsoft.Build.Utilities.Core" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-15.1.0.0" newVersion="15.1.0.0" />
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="Microsoft.Build.Tasks.Core" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-15.1.0.0" newVersion="15.1.0.0" />
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="Microsoft.IO.Redist" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-6.1.0.0" newVersion="6.1.0.0" />
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Buffers" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-4.0.4.0" newVersion="4.0.4.0" />
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Collections.Immutable" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-9.0.0.0" newVersion="9.0.0.0" />
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Memory" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-4.0.2.0" newVersion="4.0.2.0" />
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Runtime.CompilerServices.Unsafe" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-6.0.1.0" newVersion="6.0.1.0" />
</dependentAssembly>
</assemblyBinding>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Threading.Tasks.Extensions" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-4.2.1.0" newVersion="4.2.1.0" />
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>
Binary file not shown.
@@ -0,0 +1,171 @@
{
"runtimeTarget": {
"name": ".NETCoreApp,Version=v8.0",
"signature": ""
},
"compilationOptions": {},
"targets": {
".NETCoreApp,Version=v8.0": {
"Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost/5.0.0-2.25567.12": {
"dependencies": {
"Microsoft.Build.Locator": "1.10.2",
"Newtonsoft.Json": "13.0.3",
"System.Collections.Immutable": "9.0.0",
"System.CommandLine": "2.0.0-rtm.25509.106"
},
"runtime": {
"Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.dll": {}
},
"resources": {
"cs/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
"locale": "cs"
},
"de/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
"locale": "de"
},
"es/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
"locale": "es"
},
"fr/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
"locale": "fr"
},
"it/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
"locale": "it"
},
"ja/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
"locale": "ja"
},
"ko/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
"locale": "ko"
},
"pl/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
"locale": "pl"
},
"pt-BR/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
"locale": "pt-BR"
},
"ru/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
"locale": "ru"
},
"tr/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
"locale": "tr"
},
"zh-Hans/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
"locale": "zh-Hans"
},
"zh-Hant/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
"locale": "zh-Hant"
}
}
},
"Microsoft.Build.Locator/1.10.2": {
"runtime": {
"lib/net8.0/Microsoft.Build.Locator.dll": {
"assemblyVersion": "1.0.0.0",
"fileVersion": "1.10.2.26959"
}
}
},
"Newtonsoft.Json/13.0.3": {
"runtime": {
"lib/net6.0/Newtonsoft.Json.dll": {
"assemblyVersion": "13.0.0.0",
"fileVersion": "13.0.3.27908"
}
}
},
"System.Collections.Immutable/9.0.0": {
"runtime": {
"lib/net8.0/System.Collections.Immutable.dll": {
"assemblyVersion": "9.0.0.0",
"fileVersion": "9.0.24.52809"
}
}
},
"System.CommandLine/2.0.0-rtm.25509.106": {
"runtime": {
"lib/net8.0/System.CommandLine.dll": {
"assemblyVersion": "2.0.0.0",
"fileVersion": "2.0.25.51006"
}
},
"resources": {
"lib/net8.0/cs/System.CommandLine.resources.dll": {
"locale": "cs"
},
"lib/net8.0/de/System.CommandLine.resources.dll": {
"locale": "de"
},
"lib/net8.0/es/System.CommandLine.resources.dll": {
"locale": "es"
},
"lib/net8.0/fr/System.CommandLine.resources.dll": {
"locale": "fr"
},
"lib/net8.0/it/System.CommandLine.resources.dll": {
"locale": "it"
},
"lib/net8.0/ja/System.CommandLine.resources.dll": {
"locale": "ja"
},
"lib/net8.0/ko/System.CommandLine.resources.dll": {
"locale": "ko"
},
"lib/net8.0/pl/System.CommandLine.resources.dll": {
"locale": "pl"
},
"lib/net8.0/pt-BR/System.CommandLine.resources.dll": {
"locale": "pt-BR"
},
"lib/net8.0/ru/System.CommandLine.resources.dll": {
"locale": "ru"
},
"lib/net8.0/tr/System.CommandLine.resources.dll": {
"locale": "tr"
},
"lib/net8.0/zh-Hans/System.CommandLine.resources.dll": {
"locale": "zh-Hans"
},
"lib/net8.0/zh-Hant/System.CommandLine.resources.dll": {
"locale": "zh-Hant"
}
}
}
}
},
"libraries": {
"Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost/5.0.0-2.25567.12": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Microsoft.Build.Locator/1.10.2": {
"type": "package",
"serviceable": true,
"sha512": "sha512-F+nLS7IpgtslyxNvtD6Jalnf5WU08lu8yfJBNQl3cbEF3AMUphs4t7nPuRYaaU8QZyGrqtVi7i73LhAe/yHx7A==",
"path": "microsoft.build.locator/1.10.2",
"hashPath": "microsoft.build.locator.1.10.2.nupkg.sha512"
},
"Newtonsoft.Json/13.0.3": {
"type": "package",
"serviceable": true,
"sha512": "sha512-HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==",
"path": "newtonsoft.json/13.0.3",
"hashPath": "newtonsoft.json.13.0.3.nupkg.sha512"
},
"System.Collections.Immutable/9.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-QhkXUl2gNrQtvPmtBTQHb0YsUrDiDQ2QS09YbtTTiSjGcf7NBqtYbrG/BE06zcBPCKEwQGzIv13IVdXNOSub2w==",
"path": "system.collections.immutable/9.0.0",
"hashPath": "system.collections.immutable.9.0.0.nupkg.sha512"
},
"System.CommandLine/2.0.0-rtm.25509.106": {
"type": "package",
"serviceable": true,
"sha512": "sha512-IdCQOFNHQfK0hu3tzWOHFJLMaiEOR/4OynmOh+IfukrTIsCR4TTDm7lpuXQyMZ0eRfIyUcz06gHGJNlILAq/6A==",
"path": "system.commandline/2.0.0-rtm.25509.106",
"hashPath": "system.commandline.2.0.0-rtm.25509.106.nupkg.sha512"
}
}
}
@@ -0,0 +1,14 @@
{
"runtimeOptions": {
"tfm": "net8.0",
"framework": {
"name": "Microsoft.NETCore.App",
"version": "8.0.0"
},
"rollForward": "Major",
"configProperties": {
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false,
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
}
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

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