Files
timetracker/timetracker.Client/Components/Pages/Stats.razor
T
2026-06-08 23:52:22 +02:00

383 lines
20 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 "/stats"
@rendermode InteractiveWebAssembly
@attribute [Authorize]
@inject ITimetrackerService TrackerService
@inject AuthenticationStateProvider AuthStateProvider
@using System.Security.Claims
@using timetracker.Shared
<PageTitle>Statistiken 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 Statistiken…</MudText>
</MudStack>
}
else
{
<MudStack Spacing="4">
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl" Style="background: #1E293B; color: white;">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudIcon Icon="@Icons.Material.Filled.BarChart" Style="color:white; font-size:2.2rem" />
<MudStack Spacing="0">
<MudText Typo="Typo.h5" Style="color:white; font-weight:700">Statistiken</MudText>
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">Auswertung deiner Arbeitsleistung und Gleitzeitkonto</MudText>
</MudStack>
</MudStack>
</MudPaper>
<MudGrid Spacing="4">
@* ── Card 1: Wochen-Fortschritt ── *@
<MudItem xs="12" md="4">
<MudCard Elevation="3" Class="rounded-xl" Style="height:100%;">
<MudCardContent>
<MudStack AlignItems="AlignItems.Center" Spacing="3">
<MudText Typo="Typo.subtitle1" Style="font-weight:700; color:#475569">Wochenfortschritt</MudText>
@{
var pct = _weekTargetHours > 0 ? (int)Math.Min((_weekWorkedHours / _weekTargetHours) * 100, 100) : 0;
var displayPct = _weekTargetHours > 0 ? (int)Math.Round((_weekWorkedHours / _weekTargetHours) * 100) : 0;
}
<div style="position:relative; display:inline-flex;">
<MudProgressCircular Value="@pct" Color="Color.Secondary" Size="Size.Large" StrokeWidth="6" Style="height:120px; width:120px;" />
<div style="position:absolute; top:0; left:0; bottom:0; right:0; display:flex; align-items:center; justify-content:center; flex-direction:column;">
<MudText Typo="Typo.h5" Style="font-weight:800; color:#0F172A;">@displayPct%</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Erreicht</MudText>
</div>
</div>
<MudStack Spacing="1" Style="width:100%;" Class="mt-2">
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Color="Color.Secondary">Arbeitszeit (Ist):</MudText>
<MudText Typo="Typo.body2" Style="font-weight:700; color:#0F172A">@FormatHours(_weekWorkedHours) Std.</MudText>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Color="Color.Secondary">Wochensoll (Soll):</MudText>
<MudText Typo="Typo.body2" Style="font-weight:700">@FormatHours(_weekTargetHours) Std.</MudText>
</MudStack>
</MudStack>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
@* ── Card 2: Wochensaldo ── *@
<MudItem xs="12" sm="6" md="4">
<MudCard Elevation="3" Class="rounded-xl" Style="height:100%; border-left: 6px solid #0EA5E9;">
<MudCardContent>
<MudStack Spacing="2">
<MudText Typo="Typo.subtitle1" Style="font-weight:700; color:#475569">Wochensaldo</MudText>
<MudStack Row="true" AlignItems="AlignItems.Center" Class="py-3">
<MudIcon Icon="@(_weekOvertimeHours >= 0 ? Icons.Material.Filled.TrendingUp : Icons.Material.Filled.TrendingDown)"
Style="@($"color:{(_weekOvertimeHours >= 0 ? "#10B981" : "#EF4444")}; font-size:3rem")" />
<MudStack Spacing="0">
<MudText Typo="Typo.h3" Style="@($"font-weight:800; color:{(_weekOvertimeHours >= 0 ? "#10B981" : "#EF4444")};")">
@FormatTs(TimeSpan.FromHours(_weekOvertimeHours), sign: true)
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Überstunden-Veränderung diese Woche</MudText>
</MudStack>
</MudStack>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
@* ── Card 3: Gesamtsaldo ── *@
<MudItem xs="12" sm="6" md="4">
<MudCard Elevation="3" Class="rounded-xl" Style="@($"height:100%; border-left: 6px solid {(_totalOvertime >= TimeSpan.Zero ? "#10B981" : "#EF4444")};")">
<MudCardContent>
<MudStack Spacing="2">
<MudText Typo="Typo.subtitle1" Style="font-weight:700; color:#475569">Gleitzeitkonto</MudText>
<MudStack Row="true" AlignItems="AlignItems.Center" Class="py-3">
<MudIcon Icon="Icons.Material.Filled.AccountBalance"
Style="@($"color:{(_totalOvertime >= TimeSpan.Zero ? "#10B981" : "#EF4444")}; font-size:3rem")" />
<MudStack Spacing="0">
<MudText Typo="Typo.h3" Style="@($"font-weight:800; color:{(_totalOvertime >= TimeSpan.Zero ? "#10B981" : "#EF4444")};")">
@FormatTs(_totalOvertime, sign: true)
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Gesamtsaldo aller erfassten Tage</MudText>
</MudStack>
</MudStack>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
@* ── Säulendiagramm (Tagesverteilung) ── *@
<MudItem xs="12">
<MudCard Elevation="3" Class="rounded-xl">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6" Style="font-weight:700; color:#0F172A">Arbeitszeitverteilung diese Woche</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Geleistete Nettoarbeitsstunden pro Wochentag</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<div class="chart-container" style="width:100%; overflow-x:auto;">
<svg viewBox="0 0 640 320" width="100%" height="320" style="min-width: 500px; display: block; overflow: visible;">
<style>
.chart-bar {
transition: height 0.4s ease, y 0.4s ease, fill 0.3s;
cursor: pointer;
}
.chart-bar:hover {
filter: brightness(1.1) drop-shadow(0px 4px 8px rgba(14, 165, 233, 0.3));
}
</style>
@{
double plotLeft = 50;
double plotRight = 580;
double plotTop = 30;
double plotBottom = 270;
double plotWidth = plotRight - plotLeft;
double plotHeight = plotBottom - plotTop;
double barWidth = 36;
double spacing = plotWidth / 7;
}
<!-- Horizontal Grid Lines -->
@for (int i = 0; i <= 4; i++)
{
double val = (_maxHoursValue / 4.0) * i;
double yPos = plotBottom - (val / _maxHoursValue) * plotHeight;
<line x1="@plotLeft" y1="@yPos" x2="@plotRight" y2="@yPos" stroke="#E2E8F0" stroke-width="1" stroke-dasharray="2" />
@((MarkupString)$"<text x=\"{plotLeft - 8}\" y=\"{yPos + 4}\" fill=\"#64748B\" font-size=\"11\" font-weight=\"500\" text-anchor=\"end\">{val:F1}h</text>")
}
<!-- Bars and Labels -->
@for (int i = 0; i < 7; i++)
{
var d = _days[i];
double xPos = plotLeft + (i * spacing) + (spacing - barWidth) / 2;
double bHeight = (d.WorkedHours / _maxHoursValue) * plotHeight;
double yPos = plotBottom - bHeight;
string fillCol = "#94A3B8"; // Default light slate
if (d.IsToday)
{
fillCol = "#0EA5E9"; // Sky Blue for today
}
else if (d.WorkedHours >= d.TargetHours && d.TargetHours > 0)
{
fillCol = "#475569"; // Slate Blue for normal completed target
}
else if (d.WorkedHours > 0 && d.TargetHours == 0)
{
fillCol = "#10B981"; // Emerald Green for weekend work (pure overtime)
}
else if (d.WorkedHours > 0)
{
fillCol = "#64748B"; // Slate Medium for incomplete target
}
else
{
fillCol = "#E2E8F0"; // Very light grey for zero hours
}
<!-- Render Rect with hover tooltip -->
<rect x="@xPos" y="@(d.WorkedHours > 0 ? yPos : plotBottom - 4)" width="@barWidth" height="@(d.WorkedHours > 0 ? bHeight : 4)"
rx="6" fill="@fillCol" class="chart-bar">
<title>@d.DayName: @FormatHours(d.WorkedHours) Std. (Soll: @FormatHours(d.TargetHours) Std.)</title>
</rect>
<!-- Value Label above bar -->
@if (d.WorkedHours > 0)
{
@((MarkupString)$"<text x=\"{xPos + barWidth / 2}\" y=\"{yPos - 6}\" fill=\"#0F172A\" font-size=\"11\" font-weight=\"700\" text-anchor=\"middle\">{FormatHours(d.WorkedHours)}</text>")
}
<!-- Wochentag Text -->
@((MarkupString)$"<text x=\"{xPos + barWidth / 2}\" y=\"{plotBottom + 20}\" fill=\"#64748B\" font-size=\"11\" font-weight=\"700\" text-anchor=\"middle\">{d.DayShortName}</text>")
}
<!-- Sollzeit Target Line (Only if target hours > 0) -->
@if (_settings.DailyTargetHours > 0)
{
double yTarget = plotBottom - (_settings.DailyTargetHours / _maxHoursValue) * plotHeight;
<line x1="@plotLeft" y1="@yTarget" x2="@plotRight" y2="@yTarget" stroke="#EF4444" stroke-width="2" stroke-dasharray="4" style="opacity: 0.75;" />
@((MarkupString)$"<text x=\"{plotRight - 10}\" y=\"{yTarget - 6}\" fill=\"#EF4444\" font-size=\"10\" font-weight=\"700\" text-anchor=\"end\">Soll ({_settings.DailyTargetHours} h)</text>")
}
</svg>
</div>
</MudCardContent>
</MudCard>
</MudItem>
@* ── Diagnostics Panel ── *@
<MudItem xs="12">
<MudExpansionPanels>
<MudExpansionPanel Text="Diagnose-Daten (Debug)">
<MudText Typo="Typo.body2">Benutzer-ID: @_userId</MudText>
<MudText Typo="Typo.body2">Wochensoll: @_weekTargetHours Std. | Netto-Arbeitszeit: @_weekWorkedHours Std. | Wochensaldo: @_weekOvertimeHours Std.</MudText>
<MudText Typo="Typo.body2">Anzahl erfasste Tage aus DB: @_rawDbDaysCount</MudText>
<MudSimpleTable Style="margin-top: 10px;">
<thead>
<tr>
<th>Datum</th>
<th>Tag</th>
<th>Ist (Std)</th>
<th>Soll (Std)</th>
<th>Ist > 0</th>
<th>Arbeitstag (Einst.)</th>
<th>Aus DB zugeordnet</th>
</tr>
</thead>
<tbody>
@foreach (var d in _days)
{
<tr>
<td>@d.Date.ToString("yyyy-MM-dd")</td>
<td>@d.DayName</td>
<td>@d.WorkedHours</td>
<td>@d.TargetHours</td>
<td>@(d.WorkedHours > 0 ? "Ja" : "Nein")</td>
<td>@(d.IsWorkDay ? "Ja" : "Nein")</td>
<td>@(_dbMatchStatus.GetValueOrDefault(d.Date, "Nein"))</td>
</tr>
}
</tbody>
</MudSimpleTable>
</MudExpansionPanel>
</MudExpansionPanels>
</MudItem>
</MudGrid>
</MudStack>
}
@code {
private bool _loading = true;
private int _userId;
private AppSettings _settings = new();
private List<DayStat> _days = [];
private TimeSpan _totalOvertime;
private double _weekWorkedHours;
private double _weekTargetHours;
private double _weekOvertimeHours;
private double _maxHoursValue = 10.0;
// Debug variables
private int _rawDbDaysCount = 0;
private Dictionary<DateOnly, string> _dbMatchStatus = new();
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
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);
var today = DateOnly.FromDateTime(DateTime.Today);
var monday = GetMonday(today);
var dbDays = await TrackerService.GetWeekAsync(_userId, monday);
_rawDbDaysCount = dbDays.Count;
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
_weekTargetHours = 0;
_weekWorkedHours = 0;
_dbMatchStatus.Clear();
_days = Enumerable.Range(0, 7).Select(i =>
{
var date = monday.AddDays(i);
bool isWorkDay = _settings.IsWorkDay(date.DayOfWeek);
double target = isWorkDay ? _settings.DailyTargetHours : 0.0;
if (isWorkDay)
{
_weekTargetHours += _settings.DailyTargetHours;
}
var wd = dbDays.FirstOrDefault(d => d.Date == date);
double worked = 0.0;
if (wd != null)
{
_dbMatchStatus[date] = $"Ja (Id={wd.Id}, Start={wd.StartTime}, Ende={wd.EndTime})";
if (wd.StartTime != null && wd.EndTime != null)
{
var gross = wd.EndTime.Value.ToTimeSpan() - wd.StartTime.Value.ToTimeSpan();
if (gross > TimeSpan.Zero)
{
var breakTotal = 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()));
worked = (gross - breakTotal).TotalHours;
if (worked < 0) worked = 0.0;
}
}
}
else
{
_dbMatchStatus[date] = "Nein";
}
_weekWorkedHours += worked;
return new DayStat
{
Date = date,
DayName = date.ToString("dddd", _deCulture),
DayShortName = date.ToString("ddd", _deCulture),
WorkedHours = worked,
TargetHours = target,
IsToday = date == today,
IsWorkDay = isWorkDay
};
}).ToList();
_weekOvertimeHours = _weekWorkedHours - _weekTargetHours;
var maxWorked = _days.Max(d => d.WorkedHours);
_maxHoursValue = Math.Max(10.0, Math.Max(maxWorked, _settings.DailyTargetHours));
_loading = false;
}
private static DateOnly GetMonday(DateOnly date)
{
int diff = ((int)date.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7;
return date.AddDays(-diff);
}
private string FormatHours(double hours)
{
var ts = TimeSpan.FromHours(hours);
return $"{(int)ts.TotalHours}:{ts.Minutes:D2}";
}
private 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 sealed class DayStat
{
public DateOnly Date { get; set; }
public string DayName { get; set; } = "";
public string DayShortName { get; set; } = "";
public double WorkedHours { get; set; }
public double TargetHours { get; set; }
public bool IsToday { get; set; }
public bool IsWorkDay { get; set; }
}
}