315 lines
14 KiB
Plaintext
315 lines
14 KiB
Plaintext
@page "/month"
|
||
@rendermode InteractiveWebAssembly
|
||
@attribute [Authorize]
|
||
@inject ITimetrackerService TrackerService
|
||
@inject IHolidayService 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; }
|
||
}
|
||
}
|