Files
timetracker/timetracker.Client/Components/Pages/Settings.razor
T
2026-06-08 16:24:51 +02:00

584 lines
32 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@page "/settings"
@rendermode InteractiveWebAssembly
@attribute [Authorize]
@inject ITimetrackerService TrackerService
@inject IHolidayService HolidayService
@inject ISnackbar Snackbar
@inject AuthenticationStateProvider AuthStateProvider
<PageTitle>Einstellungen Timetracker</PageTitle>
@if (_settings == null)
{
<MudStack AlignItems="AlignItems.Center" Class="mt-16">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
</MudStack>
}
else
{
<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.Settings" Style="color:white; 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)">
Arbeitszeit, Arbeitstage und Urlaub konfigurieren
</MudText>
</MudStack>
</MudStack>
</MudPaper>
<MudGrid Spacing="4">
@* ── Arbeitszeit ── *@
<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.Schedule" Color="Color.Primary" />
<MudText Typo="Typo.h6" Style="font-weight:600">Arbeitszeit</MudText>
</MudStack>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudStack Spacing="4">
<MudNumericField @bind-Value="_settings.DailyTargetHours"
Label="Sollstunden pro Tag (h)"
Variant="Variant.Outlined"
Min="0.5" Max="24.0" Step="0.25"
Format="0.##"
HelperText="Vertraglich vereinbarte Nettoarbeitszeit" />
<MudNumericField @bind-Value="_settings.MinimumBreakMinutes"
Label="Gesetzliche Mindestpause (min)"
Variant="Variant.Outlined"
Min="0" Max="120" Step="5"
HelperText="Pflichtpause laut Arbeitszeitgesetz" />
<MudPaper Elevation="0" Class="pa-3 rounded-lg"
Style="background: var(--mud-palette-background-grey);">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary" Class="mb-2">
Tagesberechnung
</MudText>
<MudStack Spacing="1">
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Color="Color.Secondary">Netto (Soll)</MudText>
<MudText Typo="Typo.body2"><b>@FormatHours(_settings.DailyTargetHours)</b></MudText>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Color="Color.Secondary">+ Mindestpause</MudText>
<MudText Typo="Typo.body2"><b>@_settings.MinimumBreakMinutes min</b></MudText>
</MudStack>
<MudDivider />
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Color="Color.Secondary">= Brutto-Anwesenheit</MudText>
<MudText Typo="Typo.body2" Color="Color.Primary">
<b>@FormatHours(_settings.DailyTargetHours + _settings.MinimumBreakMinutes / 60.0)</b>
</MudText>
</MudStack>
</MudStack>
</MudPaper>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
@* ── Arbeitstage ── *@
<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.CalendarToday" Color="Color.Primary" />
<MudText Typo="Typo.h6" Style="font-weight:600">Arbeitstage</MudText>
</MudStack>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3">
Wähle die Wochentage, an denen du arbeitest.
</MudText>
<MudStack Spacing="2">
@foreach (var (label, getter, setter) in WorkDayToggles)
{
var isChecked = getter(_settings);
<MudPaper Elevation="0" Class="pa-2 rounded-lg"
Style="@($"background: {(isChecked ? "rgba(63,81,181,0.08)" : "var(--mud-palette-background-grey)")};")">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.Circle"
Style="@($"font-size:10px; color:{(isChecked ? "#3F51B5" : "#CFD8DC")}")" />
<MudText Typo="Typo.body1" Style="@(isChecked ? "font-weight:600" : "")">
@label
</MudText>
</MudStack>
<MudSwitch Value="@isChecked"
ValueChanged="@((bool v) => setter(_settings, v))"
Color="Color.Primary" />
</MudStack>
</MudPaper>
}
</MudStack>
</MudCardContent>
</MudCard>
</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>
@* ── Speichern-Button ── *@
<MudButton Variant="Variant.Filled" Color="Color.Primary"
OnClick="Save" StartIcon="@Icons.Material.Filled.Save"
Size="Size.Large" Style="max-width:300px">
Einstellungen speichern
</MudButton>
<MudDivider />
@* ── Urlaubsverwaltung ── *@
<MudCard Elevation="3" Class="rounded-xl">
<MudCardHeader Style="background: linear-gradient(90deg, rgba(0,150,136,0.1) 0%, transparent 100%);">
<CardHeaderContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Wrap="Wrap.Wrap">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.BeachAccess" Color="Color.Secondary" />
<MudText Typo="Typo.h6" Style="font-weight:600">Urlaubsverwaltung</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
Size="Size.Small" OnClick="@(() => ChangeYear(-1))" />
<MudText Typo="Typo.h6" Style="font-weight:700; min-width:50px; text-align:center">
@_vacYear
</MudText>
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
Size="Size.Small" OnClick="@(() => ChangeYear(1))" />
</MudStack>
</MudStack>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudGrid Spacing="4">
@* ── Urlaubskontingent ── *@
<MudItem xs="12" md="5">
<MudStack Spacing="3">
<MudNumericField @bind-Value="_settings.VacationDaysPerYear"
Label="Urlaubstage pro Jahr"
Variant="Variant.Outlined"
Min="1" Max="365" Step="1"
HelperText="Dein jährliches Urlaubskontingent" />
@* ── Statistik-Chips ── *@
<MudGrid Spacing="2">
<MudItem xs="4">
<MudPaper Elevation="0" Class="pa-3 rounded-lg text-center"
Style="background: rgba(63,81,181,0.08);">
<MudText Typo="Typo.h5" Color="Color.Primary" Style="font-weight:700">
@_settings.VacationDaysPerYear
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Gesamt</MudText>
</MudPaper>
</MudItem>
<MudItem xs="4">
<MudPaper Elevation="0" Class="pa-3 rounded-lg text-center"
Style="background: rgba(244,67,54,0.08);">
<MudText Typo="Typo.h5" Color="Color.Error" Style="font-weight:700">
@_vacationDays.Count
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Genommen</MudText>
</MudPaper>
</MudItem>
<MudItem xs="4">
<MudPaper Elevation="0" Class="pa-3 rounded-lg text-center"
Style="@($"background: rgba({(_vacRemaining >= 0 ? "76,175,80" : "255,152,0")},0.08);")">
<MudText Typo="Typo.h5"
Color="@(_vacRemaining >= 0 ? Color.Success : Color.Warning)"
Style="font-weight:700">
@_vacRemaining
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Verbleibend</MudText>
</MudPaper>
</MudItem>
</MudGrid>
@* ── Fortschrittsbalken ── *@
<MudTooltip Text="@($"{_vacationDays.Count} von {_settings.VacationDaysPerYear} Tagen genommen")">
<MudProgressLinear Value="@Math.Min(VacationUsedPercent, 100)"
Color="@(VacationUsedPercent > 100 ? Color.Error : VacationUsedPercent > 80 ? Color.Warning : Color.Success)"
Rounded="true" Style="height:10px" />
</MudTooltip>
<MudText Typo="Typo.caption" Color="Color.Secondary" Align="Align.Center">
@VacationUsedPercent % des Jahresurlaubs @_vacYear verbraucht
</MudText>
</MudStack>
</MudItem>
@* ── Urlaub hinzufügen ── *@
<MudItem xs="12" md="7">
<MudStack Spacing="3">
<MudText Typo="Typo.subtitle2" Style="font-weight:600">Urlaub eintragen</MudText>
<MudStack Row="true" AlignItems="AlignItems.End" Spacing="2" Wrap="Wrap.Wrap">
<MudDatePicker @bind-Date="_newVacDateFrom"
Label="Von"
Variant="Variant.Outlined"
DateFormat="dd.MM.yyyy"
Style="width:300px"
PickerVariant="PickerVariant.Inline" />
<MudDatePicker @bind-Date="_newVacDateTo"
Label="Bis"
Variant="Variant.Outlined"
DateFormat="dd.MM.yyyy"
Style="width:300px"
MinDate="@_newVacDateFrom"
PickerVariant="PickerVariant.Inline" />
<MudTextField @bind-Value="_newVacNote"
Label="Notiz (optional)"
Variant="Variant.Outlined"
Style="width:300px" />
<MudButton Variant="Variant.Filled" Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="AddVacation" Disabled="@(_newVacDateFrom == null)">
Hinzufügen
</MudButton>
</MudStack>
@* ── Liste der Urlaubstage ── *@
@if (_vacationDays.Count == 0)
{
<MudPaper Elevation="0" Class="pa-4 rounded-lg text-center"
Style="background: var(--mud-palette-background-grey);">
<MudIcon Icon="@Icons.Material.Filled.BeachAccess"
Style="font-size:2.5rem; color:#CFD8DC" />
<MudText Color="Color.Secondary" Class="mt-1">
Noch keine Urlaubstage für @_vacYear eingetragen.
</MudText>
</MudPaper>
}
else
{
<MudList T="VacationDay" Dense="true">
@foreach (var v in _vacationDays)
{
<MudListItem>
<MudStack Row="true" AlignItems="AlignItems.Center"
Justify="Justify.SpaceBetween">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.BeachAccess"
Size="Size.Small" Color="Color.Secondary" />
<MudStack Spacing="0">
<MudText Typo="Typo.body2" Style="font-weight:600">
@v.Date.ToString("dddd, dd. MMMM yyyy", _deCulture)
</MudText>
@if (!string.IsNullOrWhiteSpace(v.Note))
{
<MudText Typo="Typo.caption" Color="Color.Secondary">
@v.Note
</MudText>
}
</MudStack>
</MudStack>
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline"
Size="Size.Small" Color="Color.Error"
OnClick="@(async () => await RemoveVacation(v.Id))" />
</MudStack>
</MudListItem>
<MudDivider />
}
</MudList>
}
</MudStack>
</MudItem>
</MudGrid>
</MudCardContent>
</MudCard>
@* ── Feiertagsverwaltung ── *@
<MudCard Elevation="3" Class="rounded-xl">
<MudCardHeader Style="background: linear-gradient(90deg, rgba(0,188,212,0.1) 0%, transparent 100%);">
<CardHeaderContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Wrap="Wrap.Wrap">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.Celebration" Color="Color.Tertiary" />
<MudText Typo="Typo.h6" Style="font-weight:600">Feiertage (Deutschland)</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
Size="Size.Small" OnClick="@(() => ChangeHolYear(-1))" />
<MudText Typo="Typo.h6" Style="font-weight:700; min-width:50px; text-align:center">
@_holYear
</MudText>
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
Size="Size.Small" OnClick="@(() => ChangeHolYear(1))" />
<MudButton Variant="Variant.Filled" Color="Color.Tertiary"
StartIcon="@Icons.Material.Filled.CloudDownload"
OnClick="FetchHolidays"
Disabled="@_fetchingHolidays"
Size="Size.Small">
@if (_fetchingHolidays)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
}
Von API laden
</MudButton>
</MudStack>
</MudStack>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
@if (_holHolidays.Count == 0)
{
<MudPaper Elevation="0" Class="pa-4 rounded-lg text-center"
Style="background: var(--mud-palette-background-grey);">
<MudIcon Icon="@Icons.Material.Filled.Celebration"
Style="font-size:2.5rem; color:#CFD8DC" />
<MudText Color="Color.Secondary" Class="mt-1">
Keine Feiertage f&#252;r @_holYear gespeichert. Klicke "Von API laden".
</MudText>
</MudPaper>
}
else
{
<MudList T="PublicHoliday" Dense="true">
@foreach (var h in _holHolidays)
{
<MudListItem>
<MudStack Row="true" AlignItems="AlignItems.Center"
Justify="Justify.SpaceBetween">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.Celebration"
Size="Size.Small" Color="Color.Tertiary" />
<MudStack Spacing="0">
<MudText Typo="Typo.body2" Style="font-weight:600">
@h.Date.ToString("dddd, dd. MMMM yyyy", _deCulture)
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">@h.Name</MudText>
</MudStack>
</MudStack>
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline"
Size="Size.Small" Color="Color.Error"
OnClick="@(async () => await DeleteHoliday(h.Id))" />
</MudStack>
</MudListItem>
<MudDivider />
}
</MudList>
}
</MudCardContent>
</MudCard>
</MudStack>
}
@code {
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
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 List<VacationDay> _vacationDays = [];
private DateTime? _newVacDateFrom;
private DateTime? _newVacDateTo;
private string _newVacNote = "";
private int _holYear = DateTime.Today.Year;
private List<PublicHoliday> _holHolidays = [];
private bool _fetchingHolidays;
private int _vacRemaining => (_settings?.VacationDaysPerYear ?? 0) - _vacationDays.Count;
private int VacationUsedPercent => _settings?.VacationDaysPerYear > 0
? (int)Math.Round(_vacationDays.Count * 100.0 / _settings.VacationDaysPerYear)
: 0;
// Arbeitstage-Konfiguration als Liste von (Label, Getter, Setter)
private static readonly (string Label, Func<AppSettings, bool> Get, Action<AppSettings, bool> Set)[] WorkDayToggles =
[
("Montag", s => s.WorkMonday, (s, v) => s.WorkMonday = v),
("Dienstag", s => s.WorkTuesday, (s, v) => s.WorkTuesday = v),
("Mittwoch", s => s.WorkWednesday, (s, v) => s.WorkWednesday = v),
("Donnerstag", s => s.WorkThursday, (s, v) => s.WorkThursday = v),
("Freitag", s => s.WorkFriday, (s, v) => s.WorkFriday = v),
("Samstag", s => s.WorkSaturday, (s, v) => s.WorkSaturday = v),
("Sonntag", s => s.WorkSunday, (s, v) => s.WorkSunday = v),
];
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);
await LoadVacations();
_holHolidays = await HolidayService.GetHolidaysAsync(_holYear, _settings.GermanState);
}
private async Task LoadVacations()
{
_vacationDays = await TrackerService.GetVacationDaysAsync(_userId, _vacYear);
}
private async Task ChangeYear(int delta)
{
_vacYear += delta;
await LoadVacations();
}
private async Task Save()
{
if (_settings == null) return;
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);
}
private async Task AddVacation()
{
if (_newVacDateFrom == null) return;
var from = DateOnly.FromDateTime(_newVacDateFrom.Value);
var to = _newVacDateTo.HasValue ? DateOnly.FromDateTime(_newVacDateTo.Value) : from;
if (to < from) to = from;
var note = string.IsNullOrWhiteSpace(_newVacNote) ? null : _newVacNote.Trim();
var current = from;
var added = 0;
while (current <= to)
{
if (_settings!.IsWorkDay(current.DayOfWeek))
{
await TrackerService.AddVacationDayAsync(new VacationDay { UserId = _userId, Date = current, Note = note });
added++;
}
current = current.AddDays(1);
}
_newVacDateFrom = null;
_newVacDateTo = null;
_newVacNote = "";
await LoadVacations();
Snackbar.Add(added == 1 ? "Urlaubstag eingetragen" : $"{added} Urlaubstage eingetragen", Severity.Success);
}
private async Task RemoveVacation(int id)
{
await TrackerService.RemoveVacationDayAsync(_userId, id);
await LoadVacations();
Snackbar.Add("Urlaubstag entfernt", Severity.Info);
}
// ── Feiertage ────────────────────────────────────────────────
private async Task ChangeHolYear(int delta)
{
_holYear += delta;
_holHolidays = await HolidayService.GetHolidaysAsync(_holYear, _settings?.GermanState);
}
private async Task FetchHolidays()
{
_fetchingHolidays = true;
var (success, message) = await HolidayService.FetchAndStoreAsync(_holYear);
_holHolidays = await HolidayService.GetHolidaysAsync(_holYear, _settings?.GermanState);
_fetchingHolidays = false;
Snackbar.Add(message, success ? Severity.Success : Severity.Error);
}
private async Task DeleteHoliday(int id)
{
await HolidayService.DeleteAsync(id);
_holHolidays = await HolidayService.GetHolidaysAsync(_holYear, _settings?.GermanState);
Snackbar.Add("Feiertag entfernt", Severity.Info);
}
private static string FormatHours(double hours)
{
var ts = TimeSpan.FromHours(hours);
return $"{(int)ts.TotalHours}:{ts.Minutes:D2} h";
}
}