Files
timetracker/Components/Pages/Month.razor
T
2026-06-07 23:36:45 +02:00

315 lines
14 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 "/month"
@rendermode InteractiveServer
@attribute [Authorize]
@inject TimetrackerService TrackerService
@inject HolidayService HolidayService
@inject AuthenticationStateProvider AuthStateProvider
<PageTitle>@_deCulture.DateTimeFormat.GetMonthName(_month) @_year Monatsü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 Monatsdaten…</MudText>
</MudStack>
}
else
{
<MudStack Spacing="3">
@* ── Monats-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="PrevMonth" />
<MudStack Spacing="0" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5" Style="color:white; font-weight:700; letter-spacing:0.5px">
@_deCulture.DateTimeFormat.GetMonthName(_month) @_year
</MudText>
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
@_subLabel
</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
@if (!IsCurrentMonth)
{
<MudButton Variant="Variant.Text" Style="color:white"
OnClick="GoToCurrentMonth" Size="Size.Small">
Heute
</MudButton>
}
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
Style="color:white" Size="Size.Large" OnClick="NextMonth" />
</MudStack>
</MudStack>
</MudPaper>
@* ── Monatstabelle ── *@
<MudCard Elevation="3" Class="rounded-xl">
<MudCardContent Class="pa-0">
<MudSimpleTable Dense="true" Striped="false" Hover="true" Style="overflow-x:auto">
<thead>
<tr style="background: rgba(63,81,181,0.08);">
<th style="font-weight:700; padding:10px 16px">Tag</th>
<th style="font-weight:700; padding:10px 16px">Start</th>
<th style="font-weight:700; padding:10px 16px">Ende</th>
<th style="font-weight:700; padding:10px 16px">Netto</th>
<th style="font-weight:700; padding:10px 16px">Gleitzeit</th>
<th style="font-weight:700; padding:10px 16px">Status</th>
</tr>
</thead>
<tbody>
@foreach (var d in _days)
{
<tr style="@GetRowStyle(d)">
<td style="padding:8px 16px; white-space:nowrap">
<MudStack Spacing="0">
<MudText Typo="Typo.body2" Style="@($"font-weight:{(d.IsToday ? "700" : "500")}")">
@d.Date.ToString("ddd, dd. MMM", _deCulture)
</MudText>
</MudStack>
</td>
<td style="padding:8px 16px">
@(d.StartTime.HasValue ? d.StartTime.Value.ToString(@"HH\:mm") : "—")
</td>
<td style="padding:8px 16px">
@(d.EndTime.HasValue ? d.EndTime.Value.ToString(@"HH\:mm") : "—")
</td>
<td style="padding:8px 16px">
@(d.Net.HasValue ? FormatTs(d.Net.Value) : "—")
</td>
<td style="padding:8px 16px; color:@(d.Overtime >= TimeSpan.Zero ? "#4CAF50" : "#FF9800"); font-weight:600">
@(d.Net.HasValue ? FormatTs(d.Overtime, sign: true) : "—")
</td>
<td style="padding:8px 16px">
@GetStatusChip(d)
</td>
</tr>
}
</tbody>
</MudSimpleTable>
</MudCardContent>
</MudCard>
@* ── Monatszusammenfassung ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, rgba(63,81,181,0.08) 0%, rgba(26,35,126,0.04) 100%); border-left: 6px solid #3F51B5;">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-4">
<MudIcon Icon="@Icons.Material.Filled.CalendarViewMonth" Color="Color.Primary" />
<MudText Typo="Typo.h6" Style="font-weight:700">Monatszusammenfassung</MudText>
</MudStack>
<MudGrid Spacing="3">
<MudItem xs="6" sm="4" md="2">
<MudStack Spacing="0" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5" Color="Color.Primary" Style="font-weight:700">
@FormatTs(_monthNet)
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Netto gesamt</MudText>
</MudStack>
</MudItem>
<MudItem xs="6" sm="4" md="2">
<MudStack Spacing="0" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5"
Style="@($"font-weight:700; color:{(_monthOvertime >= TimeSpan.Zero ? "#4CAF50" : "#FF9800")}")">
@FormatTs(_monthOvertime, sign: true)
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Gleitzeit</MudText>
</MudStack>
</MudItem>
<MudItem xs="6" sm="4" md="2">
<MudStack Spacing="0" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5" Color="Color.Default" Style="font-weight:700">
@_recordedWorkDays / @_totalWorkDays
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Arbeitstage</MudText>
</MudStack>
</MudItem>
<MudItem xs="6" sm="4" md="2">
<MudStack Spacing="0" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5" Color="Color.Secondary" Style="font-weight:700">
@_vacationCount
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Urlaubstage</MudText>
</MudStack>
</MudItem>
<MudItem xs="6" sm="4" md="2">
<MudStack Spacing="0" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5" Color="Color.Tertiary" Style="font-weight:700">
@_holidayCount
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Feiertage</MudText>
</MudStack>
</MudItem>
</MudGrid>
</MudPaper>
</MudStack>
}
@code {
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
private bool _loading = true;
private int _userId;
private int _year = DateTime.Today.Year;
private int _month = DateTime.Today.Month;
private List<MonthDayVm> _days = [];
private AppSettings _settings = new();
private string _subLabel = "";
private TimeSpan _monthNet;
private TimeSpan _monthOvertime;
private int _recordedWorkDays;
private int _totalWorkDays;
private int _vacationCount;
private int _holidayCount;
private bool IsCurrentMonth => _year == DateTime.Today.Year && _month == DateTime.Today.Month;
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 LoadMonth();
_loading = false;
}
private async Task LoadMonth()
{
var workDays = await TrackerService.GetMonthAsync(_userId, _year, _month);
var holidays = await HolidayService.GetHolidaysAsync(_year, _settings.GermanState);
var vacations = await TrackerService.GetVacationDaysAsync(_userId, _year);
var holidayMap = holidays.ToDictionary(h => h.Date, h => h.Name);
var vacationSet = vacations.Select(v => v.Date).ToHashSet();
var daysInMonth = DateTime.DaysInMonth(_year, _month);
var today = DateOnly.FromDateTime(DateTime.Today);
_days = Enumerable.Range(1, daysInMonth).Select(day =>
{
var date = new DateOnly(_year, _month, day);
var wd = workDays.FirstOrDefault(w => w.Date == date);
var isWorkDay = _settings.IsWorkDay(date.DayOfWeek);
TimeSpan? net = null;
if (wd?.StartTime != null && wd.EndTime != null)
{
var gross = wd.EndTime.Value.ToTimeSpan() - wd.StartTime.Value.ToTimeSpan();
var breaks = wd.Breaks
.Where(b => b.StartTime.HasValue && b.EndTime.HasValue && b.EndTime > b.StartTime)
.Aggregate(TimeSpan.Zero, (s, b) =>
s + (b.EndTime!.Value.ToTimeSpan() - b.StartTime!.Value.ToTimeSpan()));
net = gross - breaks;
if (net < TimeSpan.Zero) net = null;
}
var target = isWorkDay ? TimeSpan.FromHours(_settings.DailyTargetHours) : TimeSpan.Zero;
var overtime = net.HasValue ? net.Value - target : TimeSpan.Zero;
return new MonthDayVm
{
Date = date,
StartTime = wd?.StartTime,
EndTime = wd?.EndTime,
Net = net,
Overtime = overtime,
HolidayName = holidayMap.GetValueOrDefault(date),
IsVacation = vacationSet.Contains(date),
IsWorkDay = isWorkDay,
IsToday = date == today
};
}).ToList();
// Summaries
_monthNet = _days.Where(d => d.Net.HasValue).Aggregate(TimeSpan.Zero, (s, d) => s + d.Net!.Value);
_monthOvertime = _days
.Where(d => d.IsWorkDay && d.Net.HasValue)
.Aggregate(TimeSpan.Zero, (s, d) => s + d.Overtime);
_recordedWorkDays = _days.Count(d => d.IsWorkDay && d.Net.HasValue);
_totalWorkDays = _days.Count(d => d.IsWorkDay && string.IsNullOrEmpty(d.HolidayName) && !d.IsVacation);
_vacationCount = _days.Count(d => d.IsVacation);
_holidayCount = _days.Count(d => !string.IsNullOrEmpty(d.HolidayName));
var recorded = _recordedWorkDays;
var total = _totalWorkDays;
_subLabel = recorded == 0 ? "Noch keine Einträge diesen Monat" : $"{recorded} von {total} Arbeitstagen erfasst";
}
private async Task PrevMonth()
{
var d = new DateOnly(_year, _month, 1).AddMonths(-1);
_year = d.Year; _month = d.Month;
await LoadMonth();
}
private async Task NextMonth()
{
var d = new DateOnly(_year, _month, 1).AddMonths(1);
_year = d.Year; _month = d.Month;
await LoadMonth();
}
private async Task GoToCurrentMonth()
{
_year = DateTime.Today.Year; _month = DateTime.Today.Month;
await LoadMonth();
}
private static string FormatTs(TimeSpan ts, bool sign = false)
{
var neg = ts < TimeSpan.Zero;
var abs = neg ? ts.Negate() : ts;
var h = (int)abs.TotalHours;
var m = abs.Minutes;
if (!sign) return $"{h}:{m:D2} h";
return neg ? $"{h}:{m:D2} h" : $"+{h}:{m:D2} h";
}
private string GetRowStyle(MonthDayVm d)
{
if (d.IsToday) return "background: rgba(63,81,181,0.10);";
if (!string.IsNullOrEmpty(d.HolidayName)) return "background: rgba(0,150,136,0.07);";
if (d.IsVacation) return "background: rgba(255,152,0,0.08);";
if (!d.IsWorkDay) return "background: rgba(0,0,0,0.03); color: #90A4AE;";
if (d.Net.HasValue) return "background: rgba(76,175,80,0.06);";
return "";
}
private RenderFragment GetStatusChip(MonthDayVm d) => builder =>
{
void Chip(string text, string color)
{
builder.OpenElement(0, "span");
builder.AddAttribute(1, "style",
$"display:inline-block; padding:2px 10px; border-radius:12px; font-size:0.75rem; font-weight:600; background:{color}20; color:{color}; border:1px solid {color}60;");
builder.AddContent(2, text);
builder.CloseElement();
}
if (d.IsToday && !d.Net.HasValue && d.IsWorkDay) Chip("Heute", "#3F51B5");
else if (!string.IsNullOrEmpty(d.HolidayName)) Chip(d.HolidayName, "#009688");
else if (d.IsVacation) Chip("Urlaub", "#FF9800");
else if (!d.IsWorkDay) Chip("Frei", "#90A4AE");
else if (d.Net.HasValue) Chip("Erfasst", "#4CAF50");
else Chip("Ausstehend", "#CFD8DC");
};
private sealed class MonthDayVm
{
public DateOnly Date { get; set; }
public TimeOnly? StartTime { get; set; }
public TimeOnly? EndTime { get; set; }
public TimeSpan? Net { get; set; }
public TimeSpan Overtime { get; set; }
public string? HolidayName { get; set; }
public bool IsVacation { get; set; }
public bool IsWorkDay { get; set; }
public bool IsToday { get; set; }
}
}