first commit

This commit is contained in:
Wieland, Marc
2026-05-22 09:18:01 +02:00
commit 88ac175190
346 changed files with 69358 additions and 0 deletions
+541
View File
@@ -0,0 +1,541 @@
@page "/"
@rendermode InteractiveServer
@inject TimetrackerService TrackerService
@inject HolidayService HolidayService
@inject ISnackbar Snackbar
<PageTitle>KW @_kw Wochenübersicht 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 Wochendaten…</MudText>
</MudStack>
}
else
{
<MudStack Spacing="3">
@* ── Wochen-Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, #3F51B5 0%, #1A237E 100%); color: white;">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
Style="color:white" Size="Size.Large" OnClick="PrevWeek" />
<MudStack Spacing="0" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5" Style="color:white; font-weight:700; letter-spacing:0.5px">
@_weekLabel
</MudText>
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
@_weekSubLabel
</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
@if (!IsCurrentWeek)
{
<MudButton Variant="Variant.Text" Style="color:white"
OnClick="GoToCurrentWeek" Size="Size.Small">
Heute
</MudButton>
}
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
Style="color:white" Size="Size.Large" OnClick="NextWeek" />
</MudStack>
</MudStack>
</MudPaper>
@* ── Tageskarten ── *@
@foreach (var day in _days)
{
var isToday = day.Date == DateOnly.FromDateTime(DateTime.Today);
var isWorkDay = _settings.IsWorkDay(day.Date.DayOfWeek);
var hasData = day.Start.HasValue || day.End.HasValue;
var overtime = GetOvertime(day);
var errors = GetDayErrors(day).ToList();
var borderColor = GetBorderColor(day, isWorkDay, errors);
var progressPct = GetProgressPercent(day);
var holidayName = _holidays.GetValueOrDefault(day.Date);
if (!isWorkDay)
{
@* ── Nicht-Arbeitstag: kompakt ── *@
<MudPaper @key="@day.Date" Elevation="1" Class="pa-3 rounded-lg"
Style="@($"border-left: 4px solid {(!string.IsNullOrEmpty(holidayName) ? "#009688" : "#CFD8DC")}; background:{(!string.IsNullOrEmpty(holidayName) ? "rgba(0,150,136,0.05)" : "#FAFAFA")};")">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudStack Spacing="0">
<MudText Typo="Typo.body1" Style="color:#90A4AE; font-weight:500">
@day.Date.ToString("dddd", _deCulture)
</MudText>
<MudText Typo="Typo.caption" Style="color:#B0BEC5">
@day.Date.ToString("dd. MMMM yyyy", _deCulture)
</MudText>
</MudStack>
@if (!string.IsNullOrEmpty(holidayName))
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled"
Style="background:#009688; color:white; font-weight:600">
@holidayName
</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined"
Style="color:#90A4AE; border-color:#CFD8DC;">
Kein Arbeitstag
</MudChip>
}
</MudStack>
</MudPaper>
}
else
{
@* ── Arbeitstag: vollständige Karte ── *@
<MudCard @key="@day.Date" Elevation="@(isToday ? 6 : 2)" Class="rounded-xl"
Style="@($"border-left: 4px solid {borderColor};")">
<MudCardHeader Style="@(isToday ? "background: linear-gradient(90deg, rgba(63,81,181,0.07) 0%, transparent 100%);" : "")">
<CardHeaderContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudStack Spacing="0">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudText Typo="Typo.h6" Style="font-weight:600">
@day.Date.ToString("dddd", _deCulture)
</MudText>
@if (isToday)
{
<MudChip T="string" Size="Size.Small" Color="Color.Primary"
Variant="Variant.Filled"
Style="height:20px; font-size:10px; font-weight:700">
HEUTE
</MudChip>
}
@if (!string.IsNullOrEmpty(holidayName))
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled"
Style="height:20px; font-size:10px; font-weight:700; background:#009688; color:white;">
@holidayName
</MudChip>
}
</MudStack>
<MudText Typo="Typo.caption" Color="Color.Secondary">
@day.Date.ToString("dd. MMMM yyyy", _deCulture)
</MudText>
</MudStack>
@if (overtime.HasValue)
{
<MudChip T="string"
Color="@(overtime.Value > TimeSpan.Zero ? Color.Success : overtime.Value < TimeSpan.Zero ? Color.Warning : Color.Info)"
Variant="Variant.Filled" Size="Size.Medium">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@(overtime.Value >= TimeSpan.Zero ? Icons.Material.Filled.TrendingUp : Icons.Material.Filled.TrendingDown)"
Size="Size.Small" />
<MudText Typo="Typo.body2"><b>@FormatTs(overtime.Value, sign: true)</b></MudText>
</MudStack>
</MudChip>
}
else if (!hasData)
{
<MudChip T="string" Color="Color.Default" Variant="Variant.Outlined" Size="Size.Small">
Nicht erfasst
</MudChip>
}
</MudStack>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent Class="pt-2">
@* ── Zeit-Eingaben + Statistik ── *@
<MudGrid Spacing="3">
<MudItem xs="6" sm="3" md="2">
<MudTimePicker Label="Beginn"
Time="@day.Start"
TimeChanged="@(async v => await OnStartChanged(day, v))"
AmPm="false" Variant="Variant.Outlined"
Clearable="true" PickerVariant="PickerVariant.Dialog"
Error="@(day.End.HasValue && day.Start.HasValue && day.Start >= day.End)" />
</MudItem>
<MudItem xs="6" sm="3" md="2">
<MudTimePicker Label="Ende"
Time="@day.End"
TimeChanged="@(async v => await OnEndChanged(day, v))"
AmPm="false" Variant="Variant.Outlined"
Clearable="true" PickerVariant="PickerVariant.Dialog"
Error="@(day.End.HasValue && day.Start.HasValue && day.Start >= day.End)" />
</MudItem>
@if (day.GrossWork.HasValue)
{
<MudItem xs="12" md="8">
<MudStack Row="true" Spacing="3" AlignItems="AlignItems.Center" Wrap="Wrap.Wrap" Class="h-100 pl-1">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Color="Color.Secondary">Brutto</MudText>
<MudText Typo="Typo.body1">@FormatTs(day.GrossWork.Value)</MudText>
</MudStack>
<MudText Color="Color.Secondary" Class="mb-1"></MudText>
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Color="Color.Secondary">Pausen</MudText>
<MudText Typo="Typo.body1">@FormatTs(day.TotalBreakTime)</MudText>
</MudStack>
<MudText Color="Color.Secondary" Class="mb-1">=</MudText>
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Color="Color.Secondary">Netto</MudText>
<MudText Typo="Typo.body1" Color="Color.Primary" Style="font-weight:700">
@FormatTs(day.NetWork!.Value)
</MudText>
</MudStack>
<MudDivider Vertical="true" FlexItem="true" Style="height:36px" />
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Color="Color.Secondary">Soll</MudText>
<MudText Typo="Typo.body1">@FormatTs(TimeSpan.FromHours(_settings.DailyTargetHours))</MudText>
</MudStack>
</MudStack>
</MudItem>
}
</MudGrid>
@* ── Fortschrittsbalken ── *@
@if (progressPct >= 0)
{
<MudTooltip Text="@($"Netto {FormatTs(day.NetWork ?? TimeSpan.Zero)} von {FormatTs(TimeSpan.FromHours(_settings.DailyTargetHours))} Soll")">
<MudProgressLinear Value="@Math.Min(progressPct, 100)"
Color="@(progressPct >= 100 ? Color.Success : Color.Primary)"
Rounded="true" Class="mt-3" Style="height:6px" />
</MudTooltip>
}
@* ── Pausen-Sektion ── *@
<MudDivider Class="mt-3 mb-2" />
<MudStack Spacing="2">
@for (int i = 0; i < day.Breaks.Count; i++)
{
var brk = day.Breaks[i];
var idx = i;
var brkError = (day.Start.HasValue && brk.Start.HasValue && brk.Start < day.Start)
|| (day.End.HasValue && brk.End.HasValue && brk.End > day.End);
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Wrap="Wrap.Wrap">
<MudIcon Icon="@Icons.Material.Filled.FreeBreakfast"
Size="Size.Small" Color="Color.Secondary" Style="opacity:0.6" />
<MudText Typo="Typo.caption" Color="Color.Secondary" Style="min-width:55px">
Pause @(idx + 1)
</MudText>
<MudTimePicker Time="@brk.Start"
TimeChanged="@(async v => await OnBreakStartChanged(day, idx, v))"
AmPm="false" Variant="Variant.Outlined"
Style="width:300px" Clearable="true"
PickerVariant="PickerVariant.Inline"
Error="@brkError" />
<MudText Color="Color.Secondary"></MudText>
<MudTimePicker Time="@brk.End"
TimeChanged="@(async v => await OnBreakEndChanged(day, idx, v))"
AmPm="false" Variant="Variant.Outlined"
Style="width:300px" Clearable="true"
PickerVariant="PickerVariant.Inline"
Error="@brkError" />
@if (brk.Duration.HasValue)
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Text" Color="Color.Secondary">
@FormatTs(brk.Duration.Value)
</MudChip>
}
<MudIconButton Icon="@Icons.Material.Filled.RemoveCircleOutline"
Size="Size.Small" Color="Color.Error"
OnClick="@(async () => await RemoveBreak(day, idx))" />
</MudStack>
}
<MudButton StartIcon="@Icons.Material.Filled.AddCircleOutline"
Variant="Variant.Text" Color="Color.Primary"
Size="Size.Small" OnClick="@(() => AddBreak(day))">
Pause hinzufügen
</MudButton>
</MudStack>
@* ── Validierungsfehler ── *@
@if (errors.Count > 0)
{
<MudDivider Class="mt-2 mb-1" />
@foreach (var err in errors)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-1">@err</MudAlert>
}
}
</MudCardContent>
</MudCard>
}
}
@* ── Wochensumme ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, #1A237E 0%, #283593 100%); color:white;">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-4">
<MudIcon Icon="@Icons.Material.Filled.Summarize" Style="color:rgba(255,255,255,0.8)" />
<MudText Typo="Typo.h6" Style="color:white; font-weight:600">Wochensumme</MudText>
</MudStack>
<MudGrid Spacing="3">
<MudItem xs="6" sm="3">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Brutto</MudText>
<MudText Typo="Typo.h5" Style="color:white">@FormatTs(WeekGross)</MudText>
</MudStack>
</MudItem>
<MudItem xs="6" sm="3">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Pausen</MudText>
<MudText Typo="Typo.h5" Style="color:white">@FormatTs(WeekBreaks)</MudText>
</MudStack>
</MudItem>
<MudItem xs="6" sm="3">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Netto</MudText>
<MudText Typo="Typo.h5" Style="color:#90CAF9; font-weight:700">@FormatTs(WeekNet)</MudText>
</MudStack>
</MudItem>
<MudItem xs="6" sm="3">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Gleitzeit</MudText>
<MudText Typo="Typo.h5" Style="@($"color:{(WeekOvertime >= TimeSpan.Zero ? "#A5D6A7" : "#FFCC80")}; font-weight:700")">
@FormatTs(WeekOvertime, sign: true)
</MudText>
</MudStack>
</MudItem>
</MudGrid>
</MudPaper>
@* ── Gleitzeitkonto ── *@
<MudPaper Elevation="3" Class="pa-5 rounded-xl"
Style="@($"border-left: 6px solid {(_totalOvertime >= TimeSpan.Zero ? "#4CAF50" : "#FF9800")};")">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Wrap="Wrap.Wrap" Spacing="3">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.AccountBalance" Color="Color.Primary" />
<MudStack Spacing="0">
<MudText Typo="Typo.h6" Style="font-weight:600">Gleitzeitkonto</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Gesamtsaldo aller erfassten Arbeitstage</MudText>
</MudStack>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@(_totalOvertime >= TimeSpan.Zero ? Icons.Material.Filled.TrendingUp : Icons.Material.Filled.TrendingDown)"
Style="@($"color:{(_totalOvertime >= TimeSpan.Zero ? "#4CAF50" : "#FF9800")}; font-size:2rem")" />
<MudText Typo="Typo.h4"
Style="@($"font-weight:700; color:{(_totalOvertime >= TimeSpan.Zero ? "#4CAF50" : "#FF9800")};")">
@FormatTs(_totalOvertime, sign: true)
</MudText>
</MudStack>
</MudStack>
</MudPaper>
</MudStack>
}
@code {
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
private bool _loading = true;
private DateOnly _monday;
private List<DayVm> _days = [];
private AppSettings _settings = new();
private string _weekLabel = "";
private string _weekSubLabel = "";
private int _kw => _monday == default ? 0 : _deCulture.Calendar.GetWeekOfYear(
_monday.ToDateTime(TimeOnly.MinValue),
System.Globalization.CalendarWeekRule.FirstFourDayWeek,
DayOfWeek.Monday);
private TimeSpan _totalOvertime;
private Dictionary<DateOnly, string> _holidays = [];
private int _holidayYear = -1;
private bool IsCurrentWeek => _monday == GetMonday(DateOnly.FromDateTime(DateTime.Today));
protected override async Task OnInitializedAsync()
{
_monday = GetMonday(DateOnly.FromDateTime(DateTime.Today));
_settings = await TrackerService.GetSettingsAsync();
await LoadWeek();
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_settings);
_loading = false;
}
private async Task LoadWeek()
{
if (_monday.Year != _holidayYear)
{
var list = await HolidayService.GetHolidaysAsync(_monday.Year);
_holidays = list.ToDictionary(h => h.Date, h => h.Name);
_holidayYear = _monday.Year;
}
var dbDays = await TrackerService.GetWeekAsync(_monday);
_days = Enumerable.Range(0, 7).Select(i =>
{
var date = _monday.AddDays(i);
return DayVm.From(dbDays.FirstOrDefault(d => d.Date == date), date);
}).ToList();
BuildWeekLabels();
}
private void BuildWeekLabels()
{
var sunday = _monday.AddDays(6);
var kw = _deCulture.Calendar.GetWeekOfYear(
_monday.ToDateTime(TimeOnly.MinValue),
System.Globalization.CalendarWeekRule.FirstFourDayWeek,
DayOfWeek.Monday);
_weekLabel = $"KW {kw} · {_monday:dd. MMM} {sunday:dd. MMM yyyy}";
var recorded = _days.Count(d => d.Start.HasValue || d.End.HasValue);
var workDays = _days.Count(d => _settings.IsWorkDay(d.Date.DayOfWeek));
_weekSubLabel = recorded == 0
? "Noch keine Einträge diese Woche"
: $"{recorded} von {workDays} Arbeitstagen erfasst";
}
// ── Navigation ──────────────────────────────────────────────
private async Task PrevWeek() { _monday = _monday.AddDays(-7); await LoadWeek(); }
private async Task NextWeek() { _monday = _monday.AddDays(7); await LoadWeek(); }
private async Task GoToCurrentWeek() { _monday = GetMonday(DateOnly.FromDateTime(DateTime.Today)); await LoadWeek(); }
private static DateOnly GetMonday(DateOnly date)
{
int diff = ((int)date.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7;
return date.AddDays(-diff);
}
// ── Change Handlers ──────────────────────────────────────────
private async Task OnStartChanged(DayVm day, TimeSpan? v) { day.Start = v; await SaveDay(day); }
private async Task OnEndChanged(DayVm day, TimeSpan? v) { day.End = v; await SaveDay(day); }
private async Task OnBreakStartChanged(DayVm day, int idx, TimeSpan? v)
{ day.Breaks[idx].Start = v; await SaveDay(day); }
private async Task OnBreakEndChanged(DayVm day, int idx, TimeSpan? v)
{ day.Breaks[idx].End = v; await SaveDay(day); }
private void AddBreak(DayVm day)
{
day.Breaks.Add(new BreakVm());
StateHasChanged();
}
private async Task RemoveBreak(DayVm day, int idx)
{ day.Breaks.RemoveAt(idx); await SaveDay(day); }
private async Task SaveDay(DayVm day)
{
await TrackerService.UpsertWorkDayAsync(day.ToWorkDay());
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_settings);
BuildWeekLabels();
}
// ── Berechnungen ─────────────────────────────────────────────
private TimeSpan? GetOvertime(DayVm day) =>
day.NetWork.HasValue ? day.NetWork.Value - TimeSpan.FromHours(_settings.DailyTargetHours) : null;
private int GetProgressPercent(DayVm day)
{
if (!day.NetWork.HasValue || _settings.DailyTargetHours <= 0) return -1;
return (int)(day.NetWork.Value.TotalHours / _settings.DailyTargetHours * 100);
}
private static string GetBorderColor(DayVm day, bool isWorkDay, List<string> errors)
{
if (!isWorkDay) return "#CFD8DC";
if (errors.Count > 0) return "#EF5350";
if (!day.Start.HasValue && !day.End.HasValue) return "#B0BEC5";
if (!day.NetWork.HasValue) return "#B0BEC5";
return "#4CAF50";
}
private IEnumerable<string> GetDayErrors(DayVm day)
{
if (day.Start.HasValue && day.End.HasValue && day.Start >= day.End)
yield return "Endzeit muss nach Beginn liegen.";
for (int i = 0; i < day.Breaks.Count; i++)
{
var b = day.Breaks[i];
if (b.Start.HasValue && b.End.HasValue && b.Start >= b.End)
yield return $"Pause {i + 1}: Ende muss nach Start liegen.";
if (day.Start.HasValue && b.Start.HasValue && b.Start < day.Start)
yield return $"Pause {i + 1} beginnt vor Arbeitsbeginn.";
if (day.End.HasValue && b.End.HasValue && b.End > day.End)
yield return $"Pause {i + 1} endet nach Arbeitsende.";
for (int j = i + 1; j < day.Breaks.Count; j++)
{
var c = day.Breaks[j];
if (b.Start.HasValue && b.End.HasValue && c.Start.HasValue && c.End.HasValue
&& b.Start < c.End && c.Start < b.End)
yield return $"Pause {i + 1} und Pause {j + 1} überschneiden sich.";
}
}
}
private TimeSpan WeekGross => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (d.GrossWork ?? TimeSpan.Zero));
private TimeSpan WeekBreaks => _days.Aggregate(TimeSpan.Zero, (s, d) => s + d.TotalBreakTime);
private TimeSpan WeekNet => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (d.NetWork ?? TimeSpan.Zero));
private TimeSpan WeekOvertime => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (GetOvertime(d) ?? TimeSpan.Zero));
private static string FormatTs(TimeSpan ts, bool sign = false)
{
if (ts == TimeSpan.Zero && sign) return "±0:00";
var prefix = sign ? (ts >= TimeSpan.Zero ? "+" : "") : (ts < TimeSpan.Zero ? "" : "");
var abs = ts.Duration();
return $"{prefix}{(int)abs.TotalHours}:{abs.Minutes:D2}";
}
// ── ViewModels ────────────────────────────────────────────────
private sealed class DayVm
{
public int Id { get; set; }
public DateOnly Date { get; set; }
public TimeSpan? Start { get; set; }
public TimeSpan? End { get; set; }
public List<BreakVm> Breaks { get; set; } = [];
public TimeSpan? GrossWork =>
Start.HasValue && End.HasValue && End > Start ? End.Value - Start.Value : null;
public TimeSpan TotalBreakTime => Breaks
.Where(b => b.Duration.HasValue)
.Aggregate(TimeSpan.Zero, (s, b) => s + b.Duration!.Value);
public TimeSpan? NetWork => GrossWork.HasValue ? GrossWork.Value - TotalBreakTime : null;
public static DayVm From(WorkDay? wd, DateOnly date) => new()
{
Id = wd?.Id ?? 0,
Date = date,
Start = wd?.StartTime?.ToTimeSpan(),
End = wd?.EndTime?.ToTimeSpan(),
Breaks = wd?.Breaks.Select(b => new BreakVm
{
Id = b.Id,
Start = b.StartTime?.ToTimeSpan(),
End = b.EndTime?.ToTimeSpan()
}).ToList() ?? []
};
public WorkDay ToWorkDay() => new()
{
Id = Id,
Date = Date,
StartTime = Start.HasValue ? TimeOnly.FromTimeSpan(Start.Value) : null,
EndTime = End.HasValue ? TimeOnly.FromTimeSpan(End.Value) : null,
Breaks = Breaks.Select(b => new BreakEntry
{
Id = b.Id,
StartTime = b.Start.HasValue ? TimeOnly.FromTimeSpan(b.Start.Value) : null,
EndTime = b.End.HasValue ? TimeOnly.FromTimeSpan(b.End.Value) : null
}).ToList()
};
}
private sealed class BreakVm
{
public int Id { get; set; }
public TimeSpan? Start { get; set; }
public TimeSpan? End { get; set; }
public TimeSpan? Duration =>
Start.HasValue && End.HasValue && End > Start ? End.Value - Start.Value : null;
}
}