Compare commits

...

7 Commits

Author SHA1 Message Date
beo3000 08b4edd1a5 Complete phase F3: Navigation finalisieren
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 16:43:36 +01:00
beo3000 245940b40e Add finalized navigation menu (F3)
- Dashboard link (all authenticated users)
- Spieltage, Personen (club-specific, require selected club)
- Stammdaten with Kostenvorlagen (ClubEditor+)
- Administration section with Vereine/Benutzer (SuperAdmin)
- Profile link for authenticated users

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 16:43:12 +01:00
beo3000 579db4640b mod claude permissions 2025-12-25 16:34:59 +01:00
beo3000 f71ff46d13 Complete phase F2: Evaluation Components
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 16:33:22 +01:00
beo3000 bc99bc25db Add evaluation components (F2)
- DayEvaluationComponent: shows day expense summary per person
- PersonEvaluationComponent: shows person expense summary + details

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 16:32:49 +01:00
beo3000 1cbd4c17e7 Complete phase F1: Dashboard Page
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 15:27:24 +01:00
beo3000 7c9f3c36d9 Add Dashboard page (F1)
- DashboardDto, IDashboardService, DashboardService
- Summary cards: members, guests, days, open expenses
- Recent days list with navigation
- Top penalty recipients list
- Home redirects to /dashboard
- Updated HomePageTests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 15:26:35 +01:00
12 changed files with 859 additions and 105 deletions

View File

@ -8,7 +8,8 @@
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(mkdir:*)",
"WebSearch"
"WebSearch",
"Bash(git merge:*)"
]
}
}

View File

@ -345,9 +345,9 @@ NavMenu.razor:
| ✓ | E2 | Days | Days List Page | 1 Razor |
| ✓ | E3 | Days | Day Details Page | 1 Razor |
| ✓ | E4 | Days | PersonExpense Management | Components in DayDetails |
| | F1 | Dashboard | Dashboard Page | 1 Razor |
| | F2 | Dashboard | Evaluation Components | 2 Shared Components |
| | F3 | Navigation | NavMenu finalisieren | 1 Razor ändern |
| | F1 | Dashboard | Dashboard Page | 1 Razor |
| | F2 | Dashboard | Evaluation Components | 2 Shared Components |
| | F3 | Navigation | NavMenu finalisieren | 1 Razor ändern |
| ☐ | G1 | Erweiterte Reg. | MembershipStatus + UserProfileClub | 2 Dateien + Migration |
| ☐ | G2 | Erweiterte Reg. | ClubInvitation Entity | 1 Entity + Migration |
| ☐ | G3 | Erweiterte Reg. | IEmailService (Stub) | 2 Dateien |

View File

@ -0,0 +1,68 @@
namespace Koogle.Application.DTOs;
/// <summary>
/// Data transfer object for dashboard summary data.
/// </summary>
public record DashboardDto
{
/// <summary>
/// Total number of active members in the club.
/// </summary>
public int MemberCount { get; init; }
/// <summary>
/// Total number of guests in the club.
/// </summary>
public int GuestCount { get; init; }
/// <summary>
/// Number of game days this year.
/// </summary>
public int DaysThisYear { get; init; }
/// <summary>
/// Total open (unpaid) expense amount across all persons.
/// </summary>
public decimal TotalOpenAmount { get; init; }
/// <summary>
/// Number of open expenses.
/// </summary>
public int OpenExpenseCount { get; init; }
/// <summary>
/// Recent game days (last 5).
/// </summary>
public List<DaySummaryDto> RecentDays { get; init; } = [];
/// <summary>
/// Top penalty recipients (top 5 by open amount).
/// </summary>
public List<TopPenaltyRecipientDto> TopPenaltyRecipients { get; init; } = [];
}
/// <summary>
/// Data transfer object for top penalty recipient.
/// </summary>
public record TopPenaltyRecipientDto
{
/// <summary>
/// Person ID.
/// </summary>
public Guid PersonId { get; init; }
/// <summary>
/// Person name.
/// </summary>
public string PersonName { get; init; } = string.Empty;
/// <summary>
/// Total open amount.
/// </summary>
public decimal OpenAmount { get; init; }
/// <summary>
/// Number of open expenses.
/// </summary>
public int ExpenseCount { get; init; }
}

View File

@ -28,6 +28,7 @@ namespace Koogle.Application
services.AddScoped<IExpenseService, ExpenseService>();
services.AddScoped<IDayService, DayService>();
services.AddScoped<IPersonExpenseService, PersonExpenseService>();
services.AddScoped<IDashboardService, DashboardService>();
return services;
}

View File

@ -0,0 +1,16 @@
using Koogle.Application.DTOs;
namespace Koogle.Application.Interfaces;
/// <summary>
/// Service interface for dashboard data aggregation.
/// </summary>
public interface IDashboardService
{
/// <summary>
/// Retrieves dashboard summary data for the current club.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>Dashboard summary data.</returns>
Task<DashboardDto> GetDashboardAsync(CancellationToken ct = default);
}

View File

@ -0,0 +1,103 @@
using Koogle.Application.DTOs;
using Koogle.Application.Interfaces;
using Koogle.Domain.Enums;
using Koogle.Domain.Interfaces;
using KoogleApp.Data;
using Microsoft.EntityFrameworkCore;
namespace Koogle.Application.Services;
/// <summary>
/// Service for aggregating dashboard data for the current club.
/// </summary>
public class DashboardService : IDashboardService
{
private readonly IDbContextFactory<AppDbContext> _contextFactory;
private readonly ICurrentClubContext _clubContext;
/// <summary>
/// Initializes a new instance of the <see cref="DashboardService"/> class.
/// </summary>
/// <param name="contextFactory">The database context factory.</param>
/// <param name="clubContext">The current club context.</param>
public DashboardService(
IDbContextFactory<AppDbContext> contextFactory,
ICurrentClubContext clubContext)
{
_contextFactory = contextFactory;
_clubContext = clubContext;
}
/// <inheritdoc />
public async Task<DashboardDto> GetDashboardAsync(CancellationToken ct = default)
{
await using var context = await _contextFactory.CreateDbContextAsync(ct);
var clubId = _clubContext.ClubId;
var currentYear = DateTime.Now.Year;
// Member and guest counts
var memberCount = await context.Persons
.CountAsync(p => p.ClubId == clubId && !p.IsDeleted && p.PersonStatus == PersonStatus.Member, ct);
var guestCount = await context.Persons
.CountAsync(p => p.ClubId == clubId && !p.IsDeleted && p.PersonStatus == PersonStatus.Guest, ct);
// Days this year
var daysThisYear = await context.Days
.CountAsync(d => d.ClubId == clubId && !d.IsDeleted && d.PostDate.Year == currentYear, ct);
// Open expenses
var openExpenses = await context.PersonExpenses
.Where(pe => pe.ClubId == clubId && !pe.IsDeleted && pe.PersonExpenseStatus == PersonExpenseStatus.Open)
.ToListAsync(ct);
var totalOpenAmount = openExpenses.Sum(pe => pe.Price);
var openExpenseCount = openExpenses.Count;
// Recent days (last 5)
var recentDays = await context.Days
.Where(d => d.ClubId == clubId && !d.IsDeleted)
.OrderByDescending(d => d.PostDate)
.Take(5)
.Include(d => d.DayPersons)
.Select(d => new DaySummaryDto
{
Id = d.Id,
PostDate = d.PostDate,
Status = d.Status,
ParticipantCount = d.DayPersons.Count
})
.ToListAsync(ct);
// Top penalty recipients (top 5 by open amount this year)
var topRecipients = await context.PersonExpenses
.Where(pe => pe.ClubId == clubId
&& !pe.IsDeleted
&& pe.PersonExpenseStatus == PersonExpenseStatus.Open
&& pe.Day.PostDate.Year == currentYear)
.Include(pe => pe.Person)
.GroupBy(pe => new { pe.PersonId, pe.Person.Name })
.Select(g => new TopPenaltyRecipientDto
{
PersonId = g.Key.PersonId,
PersonName = g.Key.Name,
OpenAmount = g.Sum(pe => pe.Price),
ExpenseCount = g.Count()
})
.OrderByDescending(t => t.OpenAmount)
.Take(5)
.ToListAsync(ct);
return new DashboardDto
{
MemberCount = memberCount,
GuestCount = guestCount,
DaysThisYear = daysThisYear,
TotalOpenAmount = totalOpenAmount,
OpenExpenseCount = openExpenseCount,
RecentDays = recentDays,
TopPenaltyRecipients = topRecipients
};
}
}

View File

@ -1,23 +1,54 @@
@inherits FluxorComponent
@using Koogle.Web.Store.ClubState
@inherits FluxorComponent
@inject IState<AuthState> AuthState
@inject IState<ClubState> ClubState
@inject NavigationManager NavigationManager
<MudNavMenu>
@* Dashboard - visible to all authenticated users *@
<MudNavLink Href="/"
Match="NavLinkMatch.All"
Icon="@Icons.Material.Filled.Dashboard">
Dashboard
</MudNavLink>
@if (AuthState.Value.IsAuthenticated && HasSelectedClub)
{
@* Club-specific navigation - requires selected club *@
<MudNavLink Href="/days"
Match="NavLinkMatch.Prefix"
Icon="@Icons.Material.Filled.CalendarMonth">
Spieltage
</MudNavLink>
<MudNavLink Href="/persons"
Match="NavLinkMatch.Prefix"
Icon="@Icons.Material.Filled.Groups">
Personen
</MudNavLink>
@if (AuthState.Value.IsClubEditor || AuthState.Value.IsSuperAdmin)
{
<MudNavGroup Title="Stammdaten"
Icon="@Icons.Material.Filled.Settings"
Expanded="false">
<MudNavLink Href="/expenses"
Match="NavLinkMatch.Prefix"
Icon="@Icons.Material.Filled.Receipt">
Kostenvorlagen
</MudNavLink>
</MudNavGroup>
}
}
@if (AuthState.Value.IsClubEditor || AuthState.Value.IsSuperAdmin)
{
@* // Tage und Auswertungen des ausgewählten Vereins *@
<MudDivider Class="my-2" />
<MudNavGroup Title="Administration"
Icon="@Icons.Material.Filled.AdminPanelSettings"
Expanded="false">
@* // Stammdaten des ausgewählten Vereins *@
@* // mandantenübergreifende Verwaltung *@
@if (AuthState.Value.IsSuperAdmin)
{
<MudNavLink Href="/admin/clubs"
@ -31,11 +62,23 @@
Benutzer
</MudNavLink>
}
</MudNavGroup>
}
<MudDivider Class="my-2" />
@* Profile - visible to all authenticated users *@
@if (AuthState.Value.IsAuthenticated)
{
<MudNavLink Href="/profile"
Match="NavLinkMatch.Prefix"
Icon="@Icons.Material.Filled.Person">
Profil
</MudNavLink>
}
</MudNavMenu>
@code {
private bool HasSelectedClub => ClubState.Value.SelectedClub != null ||
AuthState.Value.CurrentUser?.ClubMemberships?.Any(c => c.IsDefault) == true;
}

View File

@ -0,0 +1,204 @@
@page "/dashboard"
@attribute [Authorize(Policy = "ClubViewer")]
@using Koogle.Application.DTOs
@using Koogle.Application.Interfaces
@using Koogle.Domain.Enums
@using Microsoft.AspNetCore.Authorization
@inject IDashboardService DashboardService
@inject NavigationManager NavigationManager
@inject ISnackbar Snackbar
<PageTitle>Dashboard</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Dashboard</MudText>
@if (_isLoading)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
else if (_error is not null)
{
<MudAlert Severity="Severity.Error" Class="mb-4">@_error</MudAlert>
}
else if (_dashboard is not null)
{
<MudGrid>
<!-- Summary Cards -->
<MudItem xs="12" sm="6" md="3">
<MudCard Elevation="2">
<MudCardContent Class="d-flex flex-column align-center">
<MudIcon Icon="@Icons.Material.Filled.People" Size="Size.Large" Color="Color.Primary" />
<MudText Typo="Typo.h4" Class="mt-2">@_dashboard.MemberCount</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">Mitglieder</MudText>
</MudCardContent>
</MudCard>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudCard Elevation="2">
<MudCardContent Class="d-flex flex-column align-center">
<MudIcon Icon="@Icons.Material.Filled.PersonAdd" Size="Size.Large" Color="Color.Info" />
<MudText Typo="Typo.h4" Class="mt-2">@_dashboard.GuestCount</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">Gäste</MudText>
</MudCardContent>
</MudCard>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudCard Elevation="2">
<MudCardContent Class="d-flex flex-column align-center">
<MudIcon Icon="@Icons.Material.Filled.CalendarMonth" Size="Size.Large" Color="Color.Success" />
<MudText Typo="Typo.h4" Class="mt-2">@_dashboard.DaysThisYear</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">Spieltage @DateTime.Now.Year</MudText>
</MudCardContent>
</MudCard>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudCard Elevation="2" Style="cursor: pointer" @onclick="NavigateToExpenses">
<MudCardContent Class="d-flex flex-column align-center">
<MudIcon Icon="@Icons.Material.Filled.MonetizationOn" Size="Size.Large" Color="Color.Warning" />
<MudText Typo="Typo.h4" Class="mt-2">@_dashboard.TotalOpenAmount.ToString("C")</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">Offene Strafen (@_dashboard.OpenExpenseCount)</MudText>
</MudCardContent>
</MudCard>
</MudItem>
<!-- Recent Days -->
<MudItem xs="12" md="6">
<MudCard Elevation="2">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">Letzte Spieltage</MudText>
</CardHeaderContent>
<CardHeaderActions>
<MudIconButton Icon="@Icons.Material.Filled.ArrowForward" OnClick="NavigateToDays" />
</CardHeaderActions>
</MudCardHeader>
<MudCardContent>
@if (_dashboard.RecentDays.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">Keine Spieltage vorhanden</MudText>
}
else
{
<MudList T="DaySummaryDto" Dense="true">
@foreach (var day in _dashboard.RecentDays)
{
<MudListItem T="DaySummaryDto" OnClick="@(() => NavigateToDayDetails(day.Id))">
<div class="d-flex justify-space-between align-center" style="width: 100%">
<div>
<MudText Typo="Typo.body1">@day.PostDate.ToString("dd.MM.yyyy")</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">@day.ParticipantCount Teilnehmer</MudText>
</div>
<MudChip T="string" Size="Size.Small" Color="GetStatusColor(day.Status)">
@GetStatusLabel(day.Status)
</MudChip>
</div>
</MudListItem>
}
</MudList>
}
</MudCardContent>
</MudCard>
</MudItem>
<!-- Top Penalty Recipients -->
<MudItem xs="12" md="6">
<MudCard Elevation="2">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">Top Strafzahler @DateTime.Now.Year</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
@if (_dashboard.TopPenaltyRecipients.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">Keine offenen Strafen</MudText>
}
else
{
<MudList T="TopPenaltyRecipientDto" Dense="true">
@foreach (var recipient in _dashboard.TopPenaltyRecipients)
{
<MudListItem T="TopPenaltyRecipientDto">
<div class="d-flex justify-space-between align-center" style="width: 100%">
<div>
<MudText Typo="Typo.body1">@recipient.PersonName</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">@recipient.ExpenseCount Strafen</MudText>
</div>
<MudChip T="string" Size="Size.Small" Color="Color.Warning">
@recipient.OpenAmount.ToString("C")
</MudChip>
</div>
</MudListItem>
}
</MudList>
}
</MudCardContent>
</MudCard>
</MudItem>
</MudGrid>
}
@code {
private DashboardDto? _dashboard;
private bool _isLoading = true;
private string? _error;
protected override async Task OnInitializedAsync()
{
await LoadDashboardAsync();
}
private async Task LoadDashboardAsync()
{
try
{
_isLoading = true;
_error = null;
_dashboard = await DashboardService.GetDashboardAsync();
}
catch (Exception ex)
{
_error = $"Fehler beim Laden des Dashboards: {ex.Message}";
Snackbar.Add(_error, Severity.Error);
}
finally
{
_isLoading = false;
}
}
private static string GetStatusLabel(DayStatus status) => status switch
{
DayStatus.New => "Neu",
DayStatus.Started => "Gestartet",
DayStatus.Postponed => "Verschoben",
DayStatus.Closed => "Abgeschlossen",
_ => status.ToString()
};
private static Color GetStatusColor(DayStatus status) => status switch
{
DayStatus.New => Color.Info,
DayStatus.Started => Color.Warning,
DayStatus.Postponed => Color.Secondary,
DayStatus.Closed => Color.Success,
_ => Color.Default
};
private void NavigateToDays()
{
NavigationManager.NavigateTo("/days");
}
private void NavigateToDayDetails(Guid dayId)
{
NavigationManager.NavigateTo($"/days/{dayId}");
}
private void NavigateToExpenses()
{
NavigationManager.NavigateTo("/expenses");
}
}

View File

@ -1,14 +1,13 @@
@page "/"
@page "/"
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@attribute [Authorize]
<PageTitle>Home</PageTitle>
@inject NavigationManager NavigationManager
<h1>Hello, world!</h1>
Welcome to your new app.
<AuthTest/>
@code {
protected override void OnInitialized()
{
NavigationManager.NavigateTo("/dashboard", replace: true);
}
}

View File

@ -0,0 +1,186 @@
@using Koogle.Application.DTOs
@using Koogle.Application.Interfaces
@using Koogle.Domain.Enums
@inject IPersonExpenseService PersonExpenseService
@inject ISnackbar Snackbar
@inject NavigationManager NavigationManager
@if (_isLoading)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
else if (_error is not null)
{
<MudAlert Severity="Severity.Error" Class="mb-4">@_error</MudAlert>
}
else if (_evaluation is not null)
{
<MudCard Elevation="2">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">Auswertung: @_evaluation.PostDate.ToString("dd.MM.yyyy")</MudText>
</CardHeaderContent>
<CardHeaderActions>
<MudChip T="string" Size="Size.Small" Color="GetStatusColor(_evaluation.Status)">
@GetStatusLabel(_evaluation.Status)
</MudChip>
</CardHeaderActions>
</MudCardHeader>
<MudCardContent>
<!-- Summary -->
<div class="d-flex justify-space-between mb-4">
<MudText Typo="Typo.body1">
<strong>Gesamt:</strong> @_evaluation.TotalAmount.ToString("C")
</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">
@_evaluation.ExpenseCount Strafen
</MudText>
</div>
@if (_evaluation.PersonEvaluations.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">Keine Strafen erfasst</MudText>
}
else
{
<MudTable Items="_evaluation.PersonEvaluations" Dense="true" Hover="true">
<HeaderContent>
<MudTh>Person</MudTh>
<MudTh Style="text-align: right">Offen</MudTh>
<MudTh Style="text-align: right">Bezahlt</MudTh>
<MudTh Style="text-align: right">Gesamt</MudTh>
<MudTh Style="text-align: center">Anzahl</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Person">
@if (ShowPersonLinks)
{
<MudLink OnClick="@(() => NavigateToPersonDetails(context.PersonId))">
@context.PersonName
</MudLink>
}
else
{
@context.PersonName
}
</MudTd>
<MudTd DataLabel="Offen" Style="text-align: right">
@if (context.OpenAmount > 0)
{
<MudText Color="Color.Warning">@context.OpenAmount.ToString("C")</MudText>
}
else
{
<MudText>-</MudText>
}
</MudTd>
<MudTd DataLabel="Bezahlt" Style="text-align: right">
@if (context.PaidAmount > 0)
{
<MudText Color="Color.Success">@context.PaidAmount.ToString("C")</MudText>
}
else
{
<MudText>-</MudText>
}
</MudTd>
<MudTd DataLabel="Gesamt" Style="text-align: right">
<strong>@context.TotalAmount.ToString("C")</strong>
</MudTd>
<MudTd DataLabel="Anzahl" Style="text-align: center">
@context.ExpenseCount
</MudTd>
</RowTemplate>
</MudTable>
}
</MudCardContent>
</MudCard>
}
@code {
/// <summary>
/// The unique identifier of the day to evaluate.
/// </summary>
[Parameter]
public Guid DayId { get; set; }
/// <summary>
/// Whether to show links to person details.
/// </summary>
[Parameter]
public bool ShowPersonLinks { get; set; } = true;
/// <summary>
/// Event callback when data is loaded.
/// </summary>
[Parameter]
public EventCallback<DayEvaluationDto> OnLoaded { get; set; }
private DayEvaluationDto? _evaluation;
private bool _isLoading = true;
private string? _error;
protected override async Task OnParametersSetAsync()
{
if (DayId != Guid.Empty)
{
await LoadEvaluationAsync();
}
}
/// <summary>
/// Reloads the evaluation data.
/// </summary>
public async Task RefreshAsync()
{
await LoadEvaluationAsync();
}
private async Task LoadEvaluationAsync()
{
try
{
_isLoading = true;
_error = null;
_evaluation = await PersonExpenseService.GetDayEvaluationAsync(DayId);
if (OnLoaded.HasDelegate)
{
await OnLoaded.InvokeAsync(_evaluation);
}
}
catch (Exception ex)
{
_error = $"Fehler beim Laden der Auswertung: {ex.Message}";
Snackbar.Add(_error, Severity.Error);
}
finally
{
_isLoading = false;
}
}
private static string GetStatusLabel(DayStatus status) => status switch
{
DayStatus.New => "Neu",
DayStatus.Started => "Gestartet",
DayStatus.Postponed => "Verschoben",
DayStatus.Closed => "Abgeschlossen",
_ => status.ToString()
};
private static Color GetStatusColor(DayStatus status) => status switch
{
DayStatus.New => Color.Info,
DayStatus.Started => Color.Warning,
DayStatus.Postponed => Color.Secondary,
DayStatus.Closed => Color.Success,
_ => Color.Default
};
private void NavigateToPersonDetails(Guid personId)
{
NavigationManager.NavigateTo($"/persons/{personId}");
}
}

View File

@ -0,0 +1,190 @@
@using Koogle.Application.DTOs
@using Koogle.Application.Interfaces
@using Koogle.Domain.Enums
@inject IPersonExpenseService PersonExpenseService
@inject IPersonService PersonService
@inject ISnackbar Snackbar
@inject NavigationManager NavigationManager
@if (_isLoading)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
else if (_error is not null)
{
<MudAlert Severity="Severity.Error" Class="mb-4">@_error</MudAlert>
}
else if (_evaluation is not null)
{
<MudCard Elevation="2">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">Auswertung: @_evaluation.PersonName</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<!-- Summary Cards -->
<MudGrid Class="mb-4">
<MudItem xs="6" sm="3">
<div class="d-flex flex-column align-center">
<MudText Typo="Typo.h5" Color="Color.Warning">@_evaluation.TotalOpenAmount.ToString("C")</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Offen</MudText>
</div>
</MudItem>
<MudItem xs="6" sm="3">
<div class="d-flex flex-column align-center">
<MudText Typo="Typo.h5" Color="Color.Success">@_evaluation.TotalPaidAmount.ToString("C")</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Bezahlt</MudText>
</div>
</MudItem>
<MudItem xs="6" sm="3">
<div class="d-flex flex-column align-center">
<MudText Typo="Typo.h5">@_evaluation.TotalExpenseCount</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Strafen</MudText>
</div>
</MudItem>
<MudItem xs="6" sm="3">
<div class="d-flex flex-column align-center">
<MudText Typo="Typo.h5">@_evaluation.DaysParticipated</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Teilnahmen</MudText>
</div>
</MudItem>
</MudGrid>
@if (ShowExpenseDetails && _expenses.Count > 0)
{
<MudDivider Class="my-4" />
<MudText Typo="Typo.subtitle1" Class="mb-2">Strafenliste</MudText>
<MudTable Items="_expenses" Dense="true" Hover="true">
<HeaderContent>
<MudTh>Datum</MudTh>
<MudTh>Strafe</MudTh>
<MudTh Style="text-align: right">Betrag</MudTh>
<MudTh Style="text-align: center">Status</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Datum">
@if (ShowDayLinks)
{
<MudLink OnClick="@(() => NavigateToDayDetails(context.DayId))">
@context.DayPostDate.ToString("dd.MM.yyyy")
</MudLink>
}
else
{
@context.DayPostDate.ToString("dd.MM.yyyy")
}
</MudTd>
<MudTd DataLabel="Strafe">@context.Name</MudTd>
<MudTd DataLabel="Betrag" Style="text-align: right">@context.Price.ToString("C")</MudTd>
<MudTd DataLabel="Status" Style="text-align: center">
<MudChip T="string" Size="Size.Small" Color="GetExpenseStatusColor(context.PersonExpenseStatus)">
@GetExpenseStatusLabel(context.PersonExpenseStatus)
</MudChip>
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager PageSizeOptions="new[] { 10, 25, 50 }" />
</PagerContent>
</MudTable>
}
</MudCardContent>
</MudCard>
}
@code {
/// <summary>
/// The unique identifier of the person to evaluate.
/// </summary>
[Parameter]
public Guid PersonId { get; set; }
/// <summary>
/// Whether to show the detailed expense list.
/// </summary>
[Parameter]
public bool ShowExpenseDetails { get; set; } = true;
/// <summary>
/// Whether to show links to day details.
/// </summary>
[Parameter]
public bool ShowDayLinks { get; set; } = true;
/// <summary>
/// Event callback when data is loaded.
/// </summary>
[Parameter]
public EventCallback<PersonEvaluationSummaryDto> OnLoaded { get; set; }
private PersonEvaluationSummaryDto? _evaluation;
private List<PersonExpenseDto> _expenses = [];
private bool _isLoading = true;
private string? _error;
protected override async Task OnParametersSetAsync()
{
if (PersonId != Guid.Empty)
{
await LoadEvaluationAsync();
}
}
/// <summary>
/// Reloads the evaluation data.
/// </summary>
public async Task RefreshAsync()
{
await LoadEvaluationAsync();
}
private async Task LoadEvaluationAsync()
{
try
{
_isLoading = true;
_error = null;
_evaluation = await PersonExpenseService.GetPersonEvaluationAsync(PersonId);
if (ShowExpenseDetails)
{
_expenses = await PersonExpenseService.GetByPersonIdAsync(PersonId);
}
if (OnLoaded.HasDelegate)
{
await OnLoaded.InvokeAsync(_evaluation);
}
}
catch (Exception ex)
{
_error = $"Fehler beim Laden der Auswertung: {ex.Message}";
Snackbar.Add(_error, Severity.Error);
}
finally
{
_isLoading = false;
}
}
private static string GetExpenseStatusLabel(PersonExpenseStatus status) => status switch
{
PersonExpenseStatus.Open => "Offen",
PersonExpenseStatus.Done => "Bezahlt",
_ => status.ToString()
};
private static Color GetExpenseStatusColor(PersonExpenseStatus status) => status switch
{
PersonExpenseStatus.Open => Color.Warning,
PersonExpenseStatus.Done => Color.Success,
_ => Color.Default
};
private void NavigateToDayDetails(Guid dayId)
{
NavigationManager.NavigateTo($"/days/{dayId}");
}
}

View File

@ -6,7 +6,6 @@ using Koogle.Application.Interfaces;
using Koogle.Web.Store.AuthState;
using Koogle.Web.Store.ClubState;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using MudBlazor;
@ -16,7 +15,7 @@ using System.Security.Claims;
namespace Koogle.Tests.Components;
/// <summary>
/// Tests for the Home page component.
/// Tests for the Home page component (now redirects to Dashboard).
/// </summary>
public class HomePageTests : IDisposable
{
@ -39,7 +38,7 @@ public class HomePageTests : IDisposable
_clubContextMock.Setup(c => c.ClubId).Returns(Guid.NewGuid());
_clubContextMock.Setup(c => c.ClubName).Returns("Test Club");
// Setup Fluxor states - required by Home page and child components
// Setup Fluxor states
_authStateMock = new Mock<IState<AuthState>>();
_authStateMock.Setup(s => s.Value).Returns(AuthState.Initial);
@ -55,7 +54,10 @@ public class HomePageTests : IDisposable
It.IsAny<string>()))
.ReturnsAsync(AuthorizationResult.Success());
// Register all services before any rendering
// Add test authorization first before any other services
_ctx.AddTestAuthorization().SetAuthorized("testuser");
// Register all services
_ctx.Services.AddMudServices(options =>
{
options.SnackbarConfiguration.PositionClass = Defaults.Classes.Position.BottomRight;
@ -68,16 +70,16 @@ public class HomePageTests : IDisposable
_ctx.Services.AddSingleton(_clubStateMock.Object);
_ctx.Services.AddSingleton(_authorizationServiceMock.Object);
// Additional Fluxor services required by components
// Additional Fluxor services
_ctx.Services.AddSingleton(new Mock<IActionSubscriber>().Object);
_ctx.Services.AddSingleton(new Mock<IStore>().Object);
// ASP.NET Core services needed by child components
// ASP.NET Core services
var httpContextAccessorMock = new Mock<IHttpContextAccessor>();
httpContextAccessorMock.Setup(x => x.HttpContext).Returns(new DefaultHttpContext());
_ctx.Services.AddSingleton(httpContextAccessorMock.Object);
// Stub complex components to avoid deep dependency chains
// Stub complex components
_ctx.ComponentFactories.AddStub<Koogle.Web.Components.Shared.LogoutButton>();
}
@ -87,89 +89,30 @@ public class HomePageTests : IDisposable
}
[Fact]
public void HomePage_RendersWelcomeMessage_WhenAuthorized()
public void HomePage_RedirectsToDashboard_WhenAuthorized()
{
// Arrange
var authContext = _ctx.AddTestAuthorization();
authContext.SetAuthorized("testuser");
_authStateMock.Setup(s => s.Value).Returns(CreateAuthState());
// Setup authenticated auth state
// Act
_ctx.RenderComponent<Koogle.Web.Components.Pages.Home>();
// Assert - Home page should redirect to Dashboard
var navMan = _ctx.Services.GetRequiredService<FakeNavigationManager>();
navMan.Uri.Should().EndWith("/dashboard");
}
[Fact]
public void HomePage_RendersNoContent_BecauseItRedirects()
{
// Arrange
_authStateMock.Setup(s => s.Value).Returns(CreateAuthState());
// Act
var cut = _ctx.RenderComponent<Koogle.Web.Components.Pages.Home>();
// Assert
cut.Markup.Should().Contain("Hello, world!");
cut.Markup.Should().Contain("Welcome to your new app");
}
[Fact]
public void HomePage_SetsPageTitle()
{
// Arrange
var authContext = _ctx.AddTestAuthorization();
authContext.SetAuthorized("testuser");
// Setup authenticated auth state
_authStateMock.Setup(s => s.Value).Returns(CreateAuthState());
// Act
var cut = _ctx.RenderComponent<Koogle.Web.Components.Pages.Home>();
// Assert
var pageTitle = cut.FindComponent<Microsoft.AspNetCore.Components.Web.PageTitle>();
pageTitle.Should().NotBeNull();
}
[Fact]
public void HomePage_DisplaysCurrentClub_WhenAuthorized()
{
// Arrange
var authContext = _ctx.AddTestAuthorization();
authContext.SetAuthorized("testuser");
_authStateMock.Setup(s => s.Value).Returns(CreateAuthState());
_clubContextMock.Setup(c => c.ClubName).Returns("Kegel Freunde 2025");
// Act
var cut = _ctx.RenderComponent<Koogle.Web.Components.Pages.Home>();
// Assert - AuthTest component shows current club name
cut.Markup.Should().Contain("Kegel Freunde 2025");
}
[Fact]
public void HomePage_ShowsUserDisplayName_WhenAuthenticated()
{
// Arrange
var authContext = _ctx.AddTestAuthorization();
authContext.SetAuthorized("testuser");
var authState = CreateAuthState("Max Mustermann");
_authStateMock.Setup(s => s.Value).Returns(authState);
// Act
var cut = _ctx.RenderComponent<Koogle.Web.Components.Pages.Home>();
// Assert - AuthTest component shows user display name
cut.Markup.Should().Contain("Max Mustermann");
}
[Fact]
public void HomePage_ShowsAuthTestComponent()
{
// Arrange
var authContext = _ctx.AddTestAuthorization();
authContext.SetAuthorized("testuser");
_authStateMock.Setup(s => s.Value).Returns(CreateAuthState());
// Act
var cut = _ctx.RenderComponent<Koogle.Web.Components.Pages.Home>();
// Assert - AuthTest component header is rendered
cut.Markup.Should().Contain("AuthTest-Component");
// Assert - Home page renders no visible content since it redirects
cut.Markup.Should().BeEmpty();
}
private static AuthState CreateAuthState(string displayName = "Test User")