@page "/" @rendermode InteractiveServer @inject TimetrackerService TrackerService @inject HolidayService HolidayService @inject ISnackbar Snackbar 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) { Nicht erfasst } @* ── Zeit-Eingaben + Statistik ── *@ @if (day.GrossWork.HasValue) { Brutto @FormatTs(day.GrossWork.Value) Pausen @FormatTs(day.TotalBreakTime) = Netto @FormatTs(day.NetWork!.Value) Soll @FormatTs(TimeSpan.FromHours(_settings.DailyTargetHours)) } @* ── 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 = TimeSpan.Zero ? "#A5D6A7" : "#FFCC80")}; font-weight:700")"> @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) } @code { private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE"); private bool _loading = true; 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 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 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 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 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; } }