fix(dashboard): optimize stats queries and add error handling

Backend:
- Add countByStatus() to MembershipRepository
- Add countByUserIdAndStatus() to MembershipRepository
- Add countByStatusAndEmailVerified() to UserRepository
- Add countByUserId() to MeteringPointRepository
- Optimize DashboardService to use count queries instead of findAll

Frontend:
- Add error signal and error message display
- Add loading spinner animation

Tests:
- Add DashboardServiceTest with 2 unit tests
- All 22 tests passing
This commit is contained in:
Bernhard Müller 2026-07-21 13:07:19 +02:00
parent c66c5fddfa
commit a2025a55e4
7 changed files with 97 additions and 15 deletions

View File

@ -23,6 +23,11 @@ public interface MembershipRepository extends JpaRepository<Membership, UUID> {
boolean existsByEnergyCommunityId(UUID energyCommunityId);
long countByStatus(MembershipStatus status);
@Query("SELECT COUNT(m) FROM Membership m JOIN m.meteringPoint mp WHERE mp.userId = :userId AND m.status = :status")
long countByUserIdAndStatus(@Param("userId") UUID userId, @Param("status") MembershipStatus status);
@Query("SELECT m.energyCommunity.id, COUNT(m) FROM Membership m WHERE m.energyCommunity.id IN :communityIds AND m.status != 'INACTIVE' GROUP BY m.energyCommunity.id")
List<Object[]> countActiveMembersForCommunities(@Param("communityIds") List<UUID> communityIds);

View File

@ -11,6 +11,8 @@ public interface MeteringPointRepository extends JpaRepository<MeteringPoint, UU
List<MeteringPoint> findAllByUserId(UUID userId);
long countByUserId(UUID userId);
boolean existsByAtNumber(String atNumber);
Optional<MeteringPoint> findByAtNumber(String atNumber);

View File

@ -25,23 +25,17 @@ public class DashboardService {
public DashboardStatsDto.AdminStats getAdminStats() {
long communities = energyCommunityRepository.count();
long pendingUsers = userRepository.findAllByStatusAndEmailVerified(RegistrationStatus.PENDING, true).size();
// Zählt alle aktiven Mitgliedschaften
long activeMemberships = membershipRepository.findAll().stream()
.filter(m -> m.getStatus() == MembershipStatus.ACTIVE)
.count();
long pendingUsers = userRepository.countByStatusAndEmailVerified(RegistrationStatus.PENDING, true);
long activeMemberships = membershipRepository.countByStatus(MembershipStatus.ACTIVE);
return new DashboardStatsDto.AdminStats(communities, pendingUsers, activeMemberships);
}
public DashboardStatsDto.UserStats getUserStats(UUID userId) {
long meteringPoints = meteringPointRepository.findAllByUserId(userId).size();
long meteringPoints = meteringPointRepository.countByUserId(userId);
long activeMemberships = membershipRepository.countByUserIdAndStatus(userId, MembershipStatus.ACTIVE);
long pendingMemberships = membershipRepository.countByUserIdAndStatus(userId, MembershipStatus.PENDING);
// Zählt die Mitgliedschaften der Zählpunkte des Users
long activeMembers = 0; // Hier idealerweise eine angepasste Query im Repository nutzen
long pendingMembers = 0;
return new DashboardStatsDto.UserStats(meteringPoints, activeMembers, pendingMembers);
return new DashboardStatsDto.UserStats(meteringPoints, activeMemberships, pendingMemberships);
}
}

View File

@ -14,5 +14,7 @@ public interface UserRepository extends JpaRepository<User, UUID> {
List<User> findAllByStatusAndEmailVerified(RegistrationStatus status, Boolean verified);
long countByStatusAndEmailVerified(RegistrationStatus status, Boolean verified);
Optional<User> findByVerificationToken(String token);
}

View File

@ -0,0 +1,65 @@
package at.mueller.eeg.backend.dashboard.service;
import at.mueller.eeg.backend.community.domain.MembershipStatus;
import at.mueller.eeg.backend.community.repository.EnergyCommunityRepository;
import at.mueller.eeg.backend.community.repository.MembershipRepository;
import at.mueller.eeg.backend.community.repository.MeteringPointRepository;
import at.mueller.eeg.backend.dashboard.api.dto.DashboardStatsDto;
import at.mueller.eeg.backend.iam.domain.RegistrationStatus;
import at.mueller.eeg.backend.iam.repository.UserRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class DashboardServiceTest {
@Mock
private EnergyCommunityRepository energyCommunityRepository;
@Mock
private UserRepository userRepository;
@Mock
private MembershipRepository membershipRepository;
@Mock
private MeteringPointRepository meteringPointRepository;
@InjectMocks
private DashboardService dashboardService;
@Test
void getAdminStats_returnsCorrectCounts() {
when(energyCommunityRepository.count()).thenReturn(5L);
when(userRepository.countByStatusAndEmailVerified(RegistrationStatus.PENDING, true)).thenReturn(3L);
when(membershipRepository.countByStatus(MembershipStatus.ACTIVE)).thenReturn(10L);
DashboardStatsDto.AdminStats stats = dashboardService.getAdminStats();
assertEquals(5, stats.totalCommunities());
assertEquals(3, stats.pendingUsers());
assertEquals(10, stats.totalActiveMemberships());
}
@Test
void getUserStats_returnsCorrectCounts() {
UUID userId = UUID.randomUUID();
when(meteringPointRepository.countByUserId(userId)).thenReturn(2L);
when(membershipRepository.countByUserIdAndStatus(userId, MembershipStatus.ACTIVE)).thenReturn(1L);
when(membershipRepository.countByUserIdAndStatus(userId, MembershipStatus.PENDING)).thenReturn(1L);
DashboardStatsDto.UserStats stats = dashboardService.getUserStats(userId);
assertEquals(2, stats.totalMeteringPoints());
assertEquals(1, stats.activeMemberships());
assertEquals(1, stats.pendingMemberships());
}
}

View File

@ -1,5 +1,12 @@
@if (isLoading()) {
<div class="text-slate-500">Lade Daten...</div>
<div class="flex justify-center items-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-emerald-600"></div>
<span class="ml-3 text-slate-500">Lade Daten...</span>
</div>
} @else if (error()) {
<div class="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<p class="text-red-600">{{ error() }}</p>
</div>
} @else if (role === 'ADMIN' && adminStats(); as stats) {
<div class="grid grid-cols-1 sm:grid-cols-3 gap-6">
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6">

View File

@ -15,17 +15,24 @@ export class DashboardOverview implements OnInit {
adminStats = signal<AdminStats | null>(null);
userStats = signal<UserStats | null>(null);
isLoading = signal(true);
error = signal<string | null>(null);
ngOnInit() {
if (this.role === 'ADMIN') {
this.dashboardService.getAdminStats().subscribe({
next: stats => { this.adminStats.set(stats); this.isLoading.set(false); },
error: () => this.isLoading.set(false)
error: () => {
this.isLoading.set(false);
this.error.set('Fehler beim Laden der Statistiken.');
}
});
} else {
this.dashboardService.getUserStats().subscribe({
next: stats => { this.userStats.set(stats); this.isLoading.set(false); },
error: () => this.isLoading.set(false)
error: () => {
this.isLoading.set(false);
this.error.set('Fehler beim Laden der Statistiken.');
}
});
}
}