@page "/"
@rendermode InteractiveWebAssembly
@attribute [Authorize]
@inject ITimetrackerService TrackerService
@inject IHolidayService HolidayService
@inject ISnackbar Snackbar
@inject AuthenticationStateProvider AuthStateProvider
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 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 IsCurrentWeek => _monday == GetMonday(DateOnly.FromDateTime(DateTime.Today));
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));
_settings = await TrackerService.GetSettingsAsync(_userId);
await LoadWeek();
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
_loading = false;
}
private async Task LoadWeek()
{
if (_monday.Year != _holidayYear)
{
var list = await HolidayService.GetHolidaysAsync(_monday.Year, _settings.GermanState);
_holidays = list.ToDictionary(h => h.Date, h => h.Name);
_holidayYear = _monday.Year;
}
var dbDays = await TrackerService.GetWeekAsync(_userId, _monday);
_days = Enumerable.Range(0, 7).Select(i =>
{
var date = _monday.AddDays(i);
return DayVm.From(dbDays.FirstOrDefault(d => d.Date == date), date, _userId);
}).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(_userId, _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 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 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()
};
}
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;
}
}