@page "/week" @rendermode InteractiveWebAssembly @attribute [Authorize] @inject ITimetrackerService TrackerService @inject IHolidayService HolidayService @inject ISnackbar Snackbar @inject AuthenticationStateProvider AuthStateProvider @inject IJSRuntime JSRuntime @inject NavigationManager NavigationManager KW @_kw – Wochenübersicht – Timetracker @if (_loading) { Lade Wochendaten… } else { @* ── Wochen-Header ── *@ @_weekLabel @_weekSubLabel @if (!IsCurrentWeek) { Heute } @* ── 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 ── *@ @day.Date.ToString("dddd", _deCulture) @day.Date.ToString("dd. MMMM yyyy", _deCulture) @if (!string.IsNullOrEmpty(holidayName)) { @holidayName } else { Kein Arbeitstag } } else { @* ── Arbeitstag: vollständige Karte ── *@ @day.Date.ToString("dddd", _deCulture) @if (isToday) { HEUTE } @if (!string.IsNullOrEmpty(holidayName)) { @holidayName } @day.Date.ToString("dd. MMMM yyyy", _deCulture) @if (overtime.HasValue) { @FormatTs(overtime.Value, sign: true) } else if (!hasData) { @if (day.IsVacation) { URLAUB Urlaub entfernen } else { Nicht erfasst Als Urlaub eintragen } } @* ── Zeit-Eingaben + Statistik ── *@ @if (day.GrossWork.HasValue) { Brutto @FormatTs(day.GrossWork.Value) Pausen @FormatTs(GetEffectiveBreakTime(day)) = Netto @FormatTs(GetEffectiveNetWork(day)!.Value) Soll @FormatTs(TimeSpan.FromHours(GetDailyTargetHours(day))) Soll-Ende @FormatTimeSpan(CalculateEarliestDeparture(day)) Uhr } else if (day.Start.HasValue) { Früheste Gehenszeit @FormatTimeSpan(CalculateEarliestDeparture(day)) Uhr (Bei Soll: @FormatTs(TimeSpan.FromHours(_settings.DailyTargetHours))h + @((int)GetRequiredBreakForTarget(day).TotalMinutes) Min. Pause) } @* ── Fortschrittsbalken ── *@ @if (progressPct >= 0) { } @* ── Pausen-Sektion ── *@ @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); Pause @(idx + 1) @if (brk.Duration.HasValue) { @FormatTs(brk.Duration.Value) } } Pause hinzufügen @* ── Validierungsfehler ── *@ @if (errors.Count > 0) { @foreach (var err in errors) { @err } } } } @* ── Wochensumme ── *@ Wochensumme Brutto @FormatTs(WeekGross) Pausen @FormatTs(WeekBreaks) Netto @FormatTs(WeekNet) Gleitzeit @FormatTs(WeekOvertime, sign: true) @* ── Gleitzeitkonto ── *@ = TimeSpan.Zero ? "#4CAF50" : "#FF9800")};")"> Gleitzeitkonto Gesamtsaldo aller erfassten Arbeitstage = TimeSpan.Zero ? "#4CAF50" : "#FF9800")}; font-size:2rem")" /> = TimeSpan.Zero ? "#4CAF50" : "#FF9800")};")"> @FormatTs(_totalOvertime, sign: true) @if (_showOnboarding) { } } @code { private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE"); [PersistentState(AllowUpdates = true)] public AppSettings? PersistentSettings { get; set; } [PersistentState(AllowUpdates = true)] public List? PersistentDays { get; set; } [PersistentState(AllowUpdates = true)] public TimeSpan? PersistentTotalOvertime { get; set; } [PersistentState(AllowUpdates = true)] public Dictionary? PersistentHolidays { get; set; } [PersistentState(AllowUpdates = true)] public int? PersistentHolidayYear { get; set; } [PersistentState(AllowUpdates = true)] public List? PersistentVacations { get; set; } [PersistentState(AllowUpdates = true)] public string? PersistentWeekLabel { get; set; } [PersistentState(AllowUpdates = true)] public string? PersistentWeekSubLabel { get; set; } private bool _loading = true; private int _userId; private DateOnly _monday; private List _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 _holidays = []; private int _holidayYear = -1; private bool _showOnboarding; private List _vacations = []; private bool IsCurrentWeek => _monday == GetMonday(DateOnly.FromDateTime(DateTime.Today)); protected override async Task OnInitializedAsync() { if (PersistentSettings != null && PersistentDays != null && PersistentTotalOvertime != null) { _settings = PersistentSettings; _days = PersistentDays; _totalOvertime = PersistentTotalOvertime.Value; _holidays = PersistentHolidays ?? []; _holidayYear = PersistentHolidayYear ?? -1; _vacations = PersistentVacations ?? []; _weekLabel = PersistentWeekLabel ?? ""; _weekSubLabel = PersistentWeekSubLabel ?? ""; _loading = false; } 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)); if (PersistentSettings == null || PersistentDays == null || PersistentTotalOvertime == null) { _settings = await TrackerService.GetSettingsAsync(_userId); var loadWeekTask = LoadWeek(); var overtimeTask = TrackerService.GetTotalOvertimeAsync(_userId, _settings); await Task.WhenAll(loadWeekTask, overtimeTask); _totalOvertime = await overtimeTask; PersistentSettings = _settings; PersistentDays = _days; PersistentTotalOvertime = _totalOvertime; PersistentHolidays = _holidays; PersistentHolidayYear = _holidayYear; PersistentVacations = _vacations; PersistentWeekLabel = _weekLabel; PersistentWeekSubLabel = _weekSubLabel; _loading = false; } try { var showOnb = await JSRuntime.InvokeAsync("localStorage.getItem", "showOnboarding"); _showOnboarding = showOnb == "true"; } catch { // Ignored during prerendering/SSR } } private async Task HandleOnboardingFinished() { _showOnboarding = false; await JSRuntime.InvokeVoidAsync("localStorage.setItem", "showOnboarding", "false"); } private async Task LoadWeek() { Task> holidaysTask; if (_monday.Year != _holidayYear) { holidaysTask = HolidayService.GetHolidaysAsync(_monday.Year, _settings.GermanState); } else { holidaysTask = Task.FromResult(new List()); } var dbDaysTask = TrackerService.GetWeekAsync(_userId, _monday); var vacationsTask = TrackerService.GetVacationDaysAsync(_userId, _monday.Year); await Task.WhenAll(holidaysTask, dbDaysTask, vacationsTask); if (_monday.Year != _holidayYear) { var list = await holidaysTask; _holidays = list.ToDictionary(h => h.Date, h => h.Name); _holidayYear = _monday.Year; } _vacations = await vacationsTask; var dbDays = await dbDaysTask; _days = Enumerable.Range(0, 7).Select(i => { var date = _monday.AddDays(i); var dayVm = DayVm.From(dbDays.FirstOrDefault(d => d.Date == date), date, _userId); dayVm.IsVacation = _vacations.Any(v => v.Date == date); return dayVm; }).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 void ExportWeekExcel() { NavigationManager.NavigateTo($"api/tracker/export/week?monday={_monday:yyyy-MM-dd}", forceLoad: true); } 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(_userId, _settings); BuildWeekLabels(); } // ── Berechnungen ───────────────────────────────────────────── private TimeSpan GetEffectiveBreakTime(DayVm day) { var enteredBreak = day.Breaks .Where(b => b.Duration.HasValue) .Aggregate(TimeSpan.Zero, (s, b) => s + b.Duration!.Value); if (!day.Start.HasValue || !day.End.HasValue) return enteredBreak; var gross = day.End.Value - day.Start.Value; int minMinutes = gross.TotalHours >= 9.0 ? 45 : 30; int finalMin = Math.Max(minMinutes, _settings.MinimumBreakMinutes); var minBreak = TimeSpan.FromMinutes(finalMin); return enteredBreak > minBreak ? enteredBreak : minBreak; } private TimeSpan? GetEffectiveNetWork(DayVm day) { if (!day.GrossWork.HasValue) return null; return day.GrossWork.Value - GetEffectiveBreakTime(day); } private double GetDailyTargetHours(DayVm day) { var isHoliday = _holidays.ContainsKey(day.Date); var isWorkDay = _settings.IsWorkDay(day.Date.DayOfWeek); if (!isWorkDay || isHoliday || day.IsVacation) { return 0.0; } return _settings.DailyTargetHours; } private TimeSpan? GetOvertime(DayVm day) { var net = GetEffectiveNetWork(day); return net.HasValue ? net.Value - TimeSpan.FromHours(GetDailyTargetHours(day)) : null; } private int GetProgressPercent(DayVm day) { var net = GetEffectiveNetWork(day); var target = GetDailyTargetHours(day); if (!net.HasValue || target <= 0) return -1; return (int)(net.Value.TotalHours / target * 100); } private string GetBorderColor(DayVm day, bool isWorkDay, List errors) { if (!isWorkDay) return "#CFD8DC"; if (errors.Count > 0) return "#EF5350"; if (day.IsVacation) return "#0EA5E9"; // Cool blue for vacation if (!day.Start.HasValue && !day.End.HasValue) return "#B0BEC5"; if (!GetEffectiveNetWork(day).HasValue) return "#B0BEC5"; return "#4CAF50"; } private IEnumerable 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."; } } var net = GetEffectiveNetWork(day); if (net.HasValue && net.Value.TotalHours > 10.0) { yield return "Die tägliche Arbeitszeit überschreitet das gesetzliche Maximum von 10 Stunden!"; } } private TimeSpan WeekGross => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (d.GrossWork ?? TimeSpan.Zero)); private TimeSpan WeekBreaks => _days.Aggregate(TimeSpan.Zero, (s, d) => s + GetEffectiveBreakTime(d)); private TimeSpan WeekNet => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (GetEffectiveNetWork(d) ?? TimeSpan.Zero)); private TimeSpan WeekOvertime => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (GetOvertime(d) ?? TimeSpan.Zero)); private static string FormatTs(TimeSpan ts, bool sign = false) { if (ts == TimeSpan.Zero && sign) return "±0:00"; var prefix = sign ? (ts >= TimeSpan.Zero ? "+" : "−") : (ts < TimeSpan.Zero ? "−" : ""); var abs = ts.Duration(); return $"{prefix}{(int)abs.TotalHours}:{abs.Minutes:D2}"; } private TimeSpan GetRequiredBreakForTarget(DayVm day) { var targetNet = _settings.DailyTargetHours; var minMinutes = targetNet >= 8.5 ? 45 : 30; var requiredBreak = TimeSpan.FromMinutes(Math.Max(minMinutes, _settings.MinimumBreakMinutes)); var enteredBreak = day.Breaks .Where(b => b.Duration.HasValue) .Aggregate(TimeSpan.Zero, (s, b) => s + b.Duration!.Value); return enteredBreak > requiredBreak ? enteredBreak : requiredBreak; } private TimeSpan CalculateEarliestDeparture(DayVm day) { if (!day.Start.HasValue) return TimeSpan.Zero; var target = TimeSpan.FromHours(_settings.DailyTargetHours); var finalBreak = GetRequiredBreakForTarget(day); return day.Start.Value + target + finalBreak; } private async Task MarkAsVacation(DayVm day) { var vacation = new VacationDay { UserId = _userId, Date = day.Date, Note = "Über Wochenübersicht eingetragen" }; await TrackerService.AddVacationDayAsync(vacation); await LoadWeek(); _totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings); Snackbar.Add($"{day.Date:dd.MM.yyyy} als Urlaub eingetragen.", Severity.Success); } private async Task RemoveVacation(DayVm day) { var item = _vacations.FirstOrDefault(v => v.Date == day.Date); if (item != null) { await TrackerService.RemoveVacationDayAsync(_userId, item.Id); await LoadWeek(); _totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings); Snackbar.Add($"Urlaub für {day.Date:dd.MM.yyyy} entfernt.", Severity.Info); } } private static string FormatTimeSpan(TimeSpan? ts) { if (!ts.HasValue) return ""; var hours = ((int)ts.Value.TotalHours) % 24; return $"{hours:D2}:{ts.Value.Minutes:D2}"; } // ── ViewModels ──────────────────────────────────────────────── public sealed class DayVm { public int Id { get; set; } public int UserId { get; set; } public DateOnly Date { get; set; } public TimeSpan? Start { get; set; } public TimeSpan? End { get; set; } public List Breaks { get; set; } = []; public bool IsVacation { 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, int userId) => new() { Id = wd?.Id ?? 0, UserId = wd?.UserId ?? userId, 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, UserId = UserId, 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() }; } public 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; } }