diff --git a/src/Koogle.Application/DTOs/DashboardDto.cs b/src/Koogle.Application/DTOs/DashboardDto.cs new file mode 100644 index 0000000..5f3d20a --- /dev/null +++ b/src/Koogle.Application/DTOs/DashboardDto.cs @@ -0,0 +1,68 @@ +namespace Koogle.Application.DTOs; + +/// +/// Data transfer object for dashboard summary data. +/// +public record DashboardDto +{ + /// + /// Total number of active members in the club. + /// + public int MemberCount { get; init; } + + /// + /// Total number of guests in the club. + /// + public int GuestCount { get; init; } + + /// + /// Number of game days this year. + /// + public int DaysThisYear { get; init; } + + /// + /// Total open (unpaid) expense amount across all persons. + /// + public decimal TotalOpenAmount { get; init; } + + /// + /// Number of open expenses. + /// + public int OpenExpenseCount { get; init; } + + /// + /// Recent game days (last 5). + /// + public List RecentDays { get; init; } = []; + + /// + /// Top penalty recipients (top 5 by open amount). + /// + public List TopPenaltyRecipients { get; init; } = []; +} + +/// +/// Data transfer object for top penalty recipient. +/// +public record TopPenaltyRecipientDto +{ + /// + /// Person ID. + /// + public Guid PersonId { get; init; } + + /// + /// Person name. + /// + public string PersonName { get; init; } = string.Empty; + + /// + /// Total open amount. + /// + public decimal OpenAmount { get; init; } + + /// + /// Number of open expenses. + /// + public int ExpenseCount { get; init; } +} diff --git a/src/Koogle.Application/DependencyInjection.cs b/src/Koogle.Application/DependencyInjection.cs index 9df2281..9d8578f 100644 --- a/src/Koogle.Application/DependencyInjection.cs +++ b/src/Koogle.Application/DependencyInjection.cs @@ -28,6 +28,7 @@ namespace Koogle.Application services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/Koogle.Application/Interfaces/IDashboardService.cs b/src/Koogle.Application/Interfaces/IDashboardService.cs new file mode 100644 index 0000000..ba445a1 --- /dev/null +++ b/src/Koogle.Application/Interfaces/IDashboardService.cs @@ -0,0 +1,16 @@ +using Koogle.Application.DTOs; + +namespace Koogle.Application.Interfaces; + +/// +/// Service interface for dashboard data aggregation. +/// +public interface IDashboardService +{ + /// + /// Retrieves dashboard summary data for the current club. + /// + /// Cancellation token. + /// Dashboard summary data. + Task GetDashboardAsync(CancellationToken ct = default); +} diff --git a/src/Koogle.Application/Services/DashboardService.cs b/src/Koogle.Application/Services/DashboardService.cs new file mode 100644 index 0000000..87466d5 --- /dev/null +++ b/src/Koogle.Application/Services/DashboardService.cs @@ -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; + +/// +/// Service for aggregating dashboard data for the current club. +/// +public class DashboardService : IDashboardService +{ + private readonly IDbContextFactory _contextFactory; + private readonly ICurrentClubContext _clubContext; + + /// + /// Initializes a new instance of the class. + /// + /// The database context factory. + /// The current club context. + public DashboardService( + IDbContextFactory contextFactory, + ICurrentClubContext clubContext) + { + _contextFactory = contextFactory; + _clubContext = clubContext; + } + + /// + public async Task 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 + }; + } +} diff --git a/src/Koogle.Web/Components/Pages/Dashboard.razor b/src/Koogle.Web/Components/Pages/Dashboard.razor new file mode 100644 index 0000000..ab079c6 --- /dev/null +++ b/src/Koogle.Web/Components/Pages/Dashboard.razor @@ -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 + +Dashboard + +Dashboard + +@if (_isLoading) +{ + +} +else if (_error is not null) +{ + @_error +} +else if (_dashboard is not null) +{ + + + + + + + @_dashboard.MemberCount + Mitglieder + + + + + + + + @_dashboard.GuestCount + Gäste + + + + + + + + @_dashboard.DaysThisYear + Spieltage @DateTime.Now.Year + + + + + + + + @_dashboard.TotalOpenAmount.ToString("C") + Offene Strafen (@_dashboard.OpenExpenseCount) + + + + + + + + + + Letzte Spieltage + + + + + + + @if (_dashboard.RecentDays.Count == 0) + { + Keine Spieltage vorhanden + } + else + { + + @foreach (var day in _dashboard.RecentDays) + { + + + + @day.PostDate.ToString("dd.MM.yyyy") + @day.ParticipantCount Teilnehmer + + + @GetStatusLabel(day.Status) + + + + } + + } + + + + + + + + + + Top Strafzahler @DateTime.Now.Year + + + + @if (_dashboard.TopPenaltyRecipients.Count == 0) + { + Keine offenen Strafen + } + else + { + + @foreach (var recipient in _dashboard.TopPenaltyRecipients) + { + + + + @recipient.PersonName + @recipient.ExpenseCount Strafen + + + @recipient.OpenAmount.ToString("C") + + + + } + + } + + + + +} + +@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"); + } +} diff --git a/src/Koogle.Web/Components/Pages/Home.razor b/src/Koogle.Web/Components/Pages/Home.razor index 489f0f0..560d1ef 100644 --- a/src/Koogle.Web/Components/Pages/Home.razor +++ b/src/Koogle.Web/Components/Pages/Home.razor @@ -1,14 +1,13 @@ -@page "/" +@page "/" @using Microsoft.AspNetCore.Authorization -@using Microsoft.AspNetCore.Components.Authorization @attribute [Authorize] -Home +@inject NavigationManager NavigationManager -Hello, world! - -Welcome to your new app. - - - +@code { + protected override void OnInitialized() + { + NavigationManager.NavigateTo("/dashboard", replace: true); + } +} diff --git a/test/Koogle.Tests/Components/HomePageTests.cs b/test/Koogle.Tests/Components/HomePageTests.cs index 2e2a74f..ab09eee 100644 --- a/test/Koogle.Tests/Components/HomePageTests.cs +++ b/test/Koogle.Tests/Components/HomePageTests.cs @@ -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; /// -/// Tests for the Home page component. +/// Tests for the Home page component (now redirects to Dashboard). /// 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>(); _authStateMock.Setup(s => s.Value).Returns(AuthState.Initial); @@ -55,7 +54,10 @@ public class HomePageTests : IDisposable It.IsAny())) .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().Object); _ctx.Services.AddSingleton(new Mock().Object); - // ASP.NET Core services needed by child components + // ASP.NET Core services var httpContextAccessorMock = new Mock(); 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(); } @@ -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(); + + // Assert - Home page should redirect to Dashboard + var navMan = _ctx.Services.GetRequiredService(); + navMan.Uri.Should().EndWith("/dashboard"); + } + + [Fact] + public void HomePage_RendersNoContent_BecauseItRedirects() + { + // Arrange _authStateMock.Setup(s => s.Value).Returns(CreateAuthState()); // Act var cut = _ctx.RenderComponent(); - // 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(); - - // Assert - var pageTitle = cut.FindComponent(); - 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(); - - // 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(); - - // 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(); - - // 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")