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>
This commit is contained in:
beo3000 2025-12-25 15:26:35 +01:00
parent 005bfebe6d
commit 7c9f3c36d9
7 changed files with 425 additions and 91 deletions

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

@ -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

@ -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")