feat(community): add complete membership flow with unit tests

Backend:
- Add MembershipResponse and PendingMembershipResponse DTOs
- Add GET /memberships/me and GET /admin/memberships/pending endpoints
- Add findByUserIdWithDetails and findPendingWithDetails queries
- Add getOwnMemberships and getPendingMemberships service methods

Frontend:
- Add membership service for API calls
- Add join-community page for users to browse and join communities
- Add my-memberships page for users to view their memberships
- Add admin-memberships page for admins to approve/reject requests
- Add routes and navigation items

Tests:
- Add MembershipServiceTest with 13 unit tests
- All 20 tests passing (UserServiceTest + MembershipServiceTest)
This commit is contained in:
Bernhard Müller 2026-07-21 12:42:07 +02:00
parent 838fdb2826
commit c66c5fddfa
15 changed files with 868 additions and 8 deletions

View File

@ -1,7 +1,10 @@
package at.mueller.eeg.backend.community.api;
import at.mueller.eeg.backend.common.security.CurrentUserId;
import at.mueller.eeg.backend.community.api.dto.EligibleCommunityResponse;
import at.mueller.eeg.backend.community.api.dto.MembershipRequest;
import at.mueller.eeg.backend.community.api.dto.MembershipResponse;
import at.mueller.eeg.backend.community.api.dto.PendingMembershipResponse;
import at.mueller.eeg.backend.community.service.MembershipService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@ -33,10 +36,22 @@ public class MembershipController {
return ResponseEntity.status(HttpStatus.CREATED).body(membershipId);
}
@GetMapping("/memberships/me")
@PreAuthorize("hasRole('MEMBER')")
public ResponseEntity<List<MembershipResponse>> getOwnMemberships(@CurrentUserId String userId) {
return ResponseEntity.ok(membershipService.getOwnMemberships(UUID.fromString(userId)));
}
// --- ADMIN ENDPUNKTE ---
@GetMapping("/admin/memberships/pending")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<List<PendingMembershipResponse>> getPendingMemberships() {
return ResponseEntity.ok(membershipService.getPendingMemberships());
}
@PutMapping("/admin/memberships/{id}/approve")
@PreAuthorize("hasRole('ADMIN')") // Verknüpfung mit deiner UserRole.ADMIN aus dem iam-Modul
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Void> approveMembership(@PathVariable UUID id) {
membershipService.approveMembership(id);
return ResponseEntity.ok().build();

View File

@ -0,0 +1,16 @@
package at.mueller.eeg.backend.community.api.dto;
import java.time.LocalDate;
import java.util.UUID;
public record MembershipResponse(
UUID id,
UUID meteringPointId,
String atNumber,
UUID energyCommunityId,
String communityName,
Integer priorityLevel,
String status,
LocalDate validFrom,
LocalDate validTo
) {}

View File

@ -0,0 +1,14 @@
package at.mueller.eeg.backend.community.api.dto;
import java.util.UUID;
public record PendingMembershipResponse(
UUID id,
String userEmail,
String firstName,
String lastName,
String atNumber,
String communityName,
Integer priorityLevel,
String status
) {}

View File

@ -25,4 +25,10 @@ public interface MembershipRepository extends JpaRepository<Membership, UUID> {
@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);
@Query("SELECT m FROM Membership m JOIN FETCH m.meteringPoint mp JOIN FETCH m.energyCommunity ec WHERE mp.userId = :userId")
List<Membership> findByUserIdWithDetails(@Param("userId") UUID userId);
@Query("SELECT m FROM Membership m JOIN FETCH m.meteringPoint mp JOIN FETCH m.energyCommunity ec JOIN FETCH mp.user u WHERE m.status = 'PENDING'")
List<Membership> findPendingWithDetails();
}

View File

@ -2,6 +2,8 @@ package at.mueller.eeg.backend.community.service;
import at.mueller.eeg.backend.community.api.dto.EligibleCommunityResponse;
import at.mueller.eeg.backend.community.api.dto.MembershipRequest;
import at.mueller.eeg.backend.community.api.dto.MembershipResponse;
import at.mueller.eeg.backend.community.api.dto.PendingMembershipResponse;
import at.mueller.eeg.backend.community.domain.EnergyCommunity;
import at.mueller.eeg.backend.community.domain.Membership;
import at.mueller.eeg.backend.community.domain.MembershipStatus;
@ -10,6 +12,8 @@ import at.mueller.eeg.backend.community.domain.state.MakoState;
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.iam.domain.User;
import at.mueller.eeg.backend.iam.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@ -17,7 +21,9 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@ -27,6 +33,7 @@ public class MembershipService {
private final EnergyCommunityRepository energyCommunityRepository;
private final MembershipRepository membershipRepository;
private final MeteringPointRepository meteringPointRepository;
private final UserRepository userRepository;
@Transactional(readOnly = true)
public List<EligibleCommunityResponse> getEligibleCommunities(UUID meteringPointId) {
@ -72,13 +79,11 @@ public class MembershipService {
throw new IllegalStateException("Die Priorität " + request.priorityLevel() + " ist bereits vergeben. Bitte wähle eine andere oder ändere zuerst deine bestehenden Mitgliedschaften.");
}
// TODO: Hier könnte man noch prüfen, ob das priorityLevel bereits für diesen Zählpunkt vergeben ist.
Membership membership = new Membership();
membership.setMeteringPoint(point);
membership.setEnergyCommunity(community);
membership.setPriorityLevel(request.priorityLevel());
membership.setStatus(MembershipStatus.PENDING); // Startet als PENDING
membership.setStatus(MembershipStatus.PENDING);
Membership saved = membershipRepository.save(membership);
log.info("Beitrittsantrag für Zählpunkt {} zu Community {} erstellt.", point.getId(), community.getId());
@ -95,13 +100,10 @@ public class MembershipService {
}
membership.setStatus(MembershipStatus.ACTIVE);
membership.setValidFrom(LocalDate.now()); // Ab heute aktiv
membership.setValidFrom(LocalDate.now());
membershipRepository.save(membership);
log.info("Mitgliedschaft {} wurde vom Admin freigeschaltet.", membershipId);
// TODO: Hier später ein Integration Event feuern (z.B. "MembershipActivatedEvent"),
// damit die mako-Domäne die Marktkommunikation (Beginn der Belieferung/Zuteilung) via EDA anstößt!
}
@Transactional
@ -119,4 +121,52 @@ public class MembershipService {
membershipRepository.save(membership);
log.info("Mitgliedschaft {} wurde vom Admin abgelehnt.", membershipId);
}
@Transactional(readOnly = true)
public List<MembershipResponse> getOwnMemberships(UUID userId) {
return membershipRepository.findByUserIdWithDetails(userId).stream()
.map(m -> new MembershipResponse(
m.getId(),
m.getMeteringPoint().getId(),
m.getMeteringPoint().getAtNumber(),
m.getEnergyCommunity().getId(),
m.getEnergyCommunity().getName(),
m.getPriorityLevel(),
m.getStatus().name(),
m.getValidFrom(),
m.getValidTo()
))
.toList();
}
@Transactional(readOnly = true)
public List<PendingMembershipResponse> getPendingMemberships() {
List<Membership> pendingMemberships = membershipRepository.findPendingWithDetails();
// Collect all user IDs from metering points
List<UUID> userIds = pendingMemberships.stream()
.map(m -> m.getMeteringPoint().getUserId())
.distinct()
.toList();
// Fetch users and create a lookup map
Map<UUID, User> usersById = userRepository.findAllById(userIds).stream()
.collect(Collectors.toMap(User::getId, user -> user));
return pendingMemberships.stream()
.map(m -> {
User user = usersById.get(m.getMeteringPoint().getUserId());
return new PendingMembershipResponse(
m.getId(),
user != null ? user.getEmail() : "unknown",
user != null ? user.getFirstName() : "unknown",
user != null ? user.getLastName() : "unknown",
m.getMeteringPoint().getAtNumber(),
m.getEnergyCommunity().getName(),
m.getPriorityLevel(),
m.getStatus().name()
);
})
.toList();
}
}

View File

@ -0,0 +1,249 @@
package at.mueller.eeg.backend.community.service;
import at.mueller.eeg.backend.community.api.dto.EligibleCommunityResponse;
import at.mueller.eeg.backend.community.api.dto.MembershipRequest;
import at.mueller.eeg.backend.community.api.dto.MembershipResponse;
import at.mueller.eeg.backend.community.api.dto.PendingMembershipResponse;
import at.mueller.eeg.backend.community.domain.CommunityType;
import at.mueller.eeg.backend.community.domain.EnergyCommunity;
import at.mueller.eeg.backend.community.domain.Membership;
import at.mueller.eeg.backend.community.domain.MembershipStatus;
import at.mueller.eeg.backend.community.domain.MeteringPoint;
import at.mueller.eeg.backend.community.domain.state.MakoState;
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.iam.domain.User;
import at.mueller.eeg.backend.iam.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
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.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class MembershipServiceTest {
@Mock
private EnergyCommunityRepository energyCommunityRepository;
@Mock
private MembershipRepository membershipRepository;
@Mock
private MeteringPointRepository meteringPointRepository;
@Mock
private UserRepository userRepository;
@InjectMocks
private MembershipService membershipService;
private MeteringPoint testMeteringPoint;
private EnergyCommunity testCommunity;
private Membership testMembership;
private User testUser;
private UUID testUserId;
private UUID testMeteringPointId;
private UUID testCommunityId;
private UUID testMembershipId;
@BeforeEach
void setUp() {
testUserId = UUID.randomUUID();
testMeteringPointId = UUID.randomUUID();
testCommunityId = UUID.randomUUID();
testMembershipId = UUID.randomUUID();
testUser = new User();
testUser.setId(testUserId);
testUser.setEmail("test@example.com");
testUser.setFirstName("Test");
testUser.setLastName("Benutzer");
testMeteringPoint = new MeteringPoint();
testMeteringPoint.setId(testMeteringPointId);
testMeteringPoint.setUserId(testUserId);
testMeteringPoint.setAtNumber("AT0010000000000000000000001234567");
testMeteringPoint.setMakoState(MakoState.ACTIVE);
testMeteringPoint.setSubstationId("UW_WAP_21_STREBERSDORF");
testMeteringPoint.setTransformerId("TRAFO_21_042_BISAMBERG");
testMeteringPoint.setGridOperatorId("Wiener Netze GmbH");
testCommunity = new EnergyCommunity();
testCommunity.setId(testCommunityId);
testCommunity.setName("EEG Wien Mitte");
testCommunity.setType(CommunityType.EEG_REGIONAL);
testCommunity.setEdaEcId("EDA-EC-001");
testCommunity.setSubstationId("UW_WAP_21_STREBERSDORF");
testCommunity.setGridOperatorId("Wiener Netze GmbH");
testMembership = new Membership();
testMembership.setId(testMembershipId);
testMembership.setMeteringPoint(testMeteringPoint);
testMembership.setEnergyCommunity(testCommunity);
testMembership.setPriorityLevel(1);
testMembership.setStatus(MembershipStatus.PENDING);
}
@Test
void getEligibleCommunities_returnsMatchingCommunities() {
when(meteringPointRepository.findById(testMeteringPointId)).thenReturn(Optional.of(testMeteringPoint));
when(energyCommunityRepository.findEligibleCommunities(
testMeteringPoint.getSubstationId(),
testMeteringPoint.getTransformerId(),
testMeteringPoint.getGridOperatorId()
)).thenReturn(List.of(testCommunity));
List<EligibleCommunityResponse> result = membershipService.getEligibleCommunities(testMeteringPointId);
assertEquals(1, result.size());
assertEquals(testCommunityId, result.get(0).id());
assertEquals("EEG Wien Mitte", result.get(0).name());
}
@Test
void getEligibleCommunities_returnsEmptyForInactivePoint() {
testMeteringPoint.setMakoState(MakoState.WAITING_FOR_CONSENT);
when(meteringPointRepository.findById(testMeteringPointId)).thenReturn(Optional.of(testMeteringPoint));
List<EligibleCommunityResponse> result = membershipService.getEligibleCommunities(testMeteringPointId);
assertTrue(result.isEmpty());
}
@Test
void getEligibleCommunities_throwsForUnknownPoint() {
when(meteringPointRepository.findById(any())).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class, () -> membershipService.getEligibleCommunities(UUID.randomUUID()));
}
@Test
void requestMembership_createsNewMembership() {
when(meteringPointRepository.findById(testMeteringPointId)).thenReturn(Optional.of(testMeteringPoint));
when(energyCommunityRepository.findById(testCommunityId)).thenReturn(Optional.of(testCommunity));
when(membershipRepository.existsByMeteringPointIdAndEnergyCommunityId(testMeteringPointId, testCommunityId)).thenReturn(false);
when(membershipRepository.existsByMeteringPointIdAndPriorityLevelAndStatusIn(
testMeteringPointId, 1, List.of(MembershipStatus.ACTIVE, MembershipStatus.PENDING)
)).thenReturn(false);
when(membershipRepository.save(any())).thenReturn(testMembership);
MembershipRequest request = new MembershipRequest(testMeteringPointId, testCommunityId, 1);
UUID result = membershipService.requestMembership(request);
assertEquals(testMembershipId, result);
verify(membershipRepository).save(any());
}
@Test
void requestMembership_throwsForInactivePoint() {
testMeteringPoint.setMakoState(MakoState.NEW);
when(meteringPointRepository.findById(testMeteringPointId)).thenReturn(Optional.of(testMeteringPoint));
when(energyCommunityRepository.findById(testCommunityId)).thenReturn(Optional.of(testCommunity));
MembershipRequest request = new MembershipRequest(testMeteringPointId, testCommunityId, 1);
assertThrows(IllegalStateException.class, () -> membershipService.requestMembership(request));
}
@Test
void requestMembership_throwsForDuplicateRequest() {
when(meteringPointRepository.findById(testMeteringPointId)).thenReturn(Optional.of(testMeteringPoint));
when(energyCommunityRepository.findById(testCommunityId)).thenReturn(Optional.of(testCommunity));
when(membershipRepository.existsByMeteringPointIdAndEnergyCommunityId(testMeteringPointId, testCommunityId)).thenReturn(true);
MembershipRequest request = new MembershipRequest(testMeteringPointId, testCommunityId, 1);
assertThrows(IllegalStateException.class, () -> membershipService.requestMembership(request));
}
@Test
void requestMembership_throwsForTakenPriority() {
when(meteringPointRepository.findById(testMeteringPointId)).thenReturn(Optional.of(testMeteringPoint));
when(energyCommunityRepository.findById(testCommunityId)).thenReturn(Optional.of(testCommunity));
when(membershipRepository.existsByMeteringPointIdAndEnergyCommunityId(testMeteringPointId, testCommunityId)).thenReturn(false);
when(membershipRepository.existsByMeteringPointIdAndPriorityLevelAndStatusIn(
testMeteringPointId, 1, List.of(MembershipStatus.ACTIVE, MembershipStatus.PENDING)
)).thenReturn(true);
MembershipRequest request = new MembershipRequest(testMeteringPointId, testCommunityId, 1);
assertThrows(IllegalStateException.class, () -> membershipService.requestMembership(request));
}
@Test
void approveMembership_setsActiveStatus() {
when(membershipRepository.findById(testMembershipId)).thenReturn(Optional.of(testMembership));
when(membershipRepository.save(any())).thenReturn(testMembership);
membershipService.approveMembership(testMembershipId);
assertEquals(MembershipStatus.ACTIVE, testMembership.getStatus());
assertEquals(LocalDate.now(), testMembership.getValidFrom());
verify(membershipRepository).save(testMembership);
}
@Test
void approveMembership_throwsForNonPendingStatus() {
testMembership.setStatus(MembershipStatus.ACTIVE);
when(membershipRepository.findById(testMembershipId)).thenReturn(Optional.of(testMembership));
assertThrows(IllegalStateException.class, () -> membershipService.approveMembership(testMembershipId));
}
@Test
void rejectMembership_setsInactiveStatus() {
when(membershipRepository.findById(testMembershipId)).thenReturn(Optional.of(testMembership));
when(membershipRepository.save(any())).thenReturn(testMembership);
membershipService.rejectMembership(testMembershipId);
assertEquals(MembershipStatus.INACTIVE, testMembership.getStatus());
assertEquals(LocalDate.now(), testMembership.getValidTo());
verify(membershipRepository).save(testMembership);
}
@Test
void rejectMembership_throwsForNonPendingStatus() {
testMembership.setStatus(MembershipStatus.ACTIVE);
when(membershipRepository.findById(testMembershipId)).thenReturn(Optional.of(testMembership));
assertThrows(IllegalStateException.class, () -> membershipService.rejectMembership(testMembershipId));
}
@Test
void getOwnMemberships_returnsUserMemberships() {
when(membershipRepository.findByUserIdWithDetails(testUserId)).thenReturn(List.of(testMembership));
List<MembershipResponse> result = membershipService.getOwnMemberships(testUserId);
assertEquals(1, result.size());
assertEquals(testMembershipId, result.get(0).id());
assertEquals("AT0010000000000000000000001234567", result.get(0).atNumber());
assertEquals("EEG Wien Mitte", result.get(0).communityName());
}
@Test
void getPendingMemberships_returnsPendingRequests() {
when(membershipRepository.findPendingWithDetails()).thenReturn(List.of(testMembership));
when(userRepository.findAllById(List.of(testUserId))).thenReturn(List.of(testUser));
List<PendingMembershipResponse> result = membershipService.getPendingMemberships();
assertEquals(1, result.size());
assertEquals(testMembershipId, result.get(0).id());
assertEquals("test@example.com", result.get(0).userEmail());
assertEquals("Test", result.get(0).firstName());
}
}

View File

@ -11,6 +11,9 @@ import {AdminEnergyCommunity} from './pages/admin-energy-community/admin-energy-
import {VerifyEmailComponent} from './pages/verify-email/verify-email';
import {MeteringPointsComponent} from './pages/metering-points/metering-points';
import {ProfileComponent} from './pages/profile/profile';
import {JoinCommunityComponent} from './pages/join-community/join-community';
import {MyMembershipsComponent} from './pages/my-memberships/my-memberships';
import {AdminMembershipsComponent} from './pages/admin-memberships/admin-memberships';
export const routes: Routes = [
{ path: '', component: LandingPage },
@ -28,6 +31,21 @@ export const routes: Routes = [
component: MeteringPointsComponent,
canActivate: [roleGuard(['ADMIN', 'MEMBER'])]
},
{
path: 'join-community',
component: JoinCommunityComponent,
canActivate: [roleGuard(['MEMBER'])]
},
{
path: 'my-memberships',
component: MyMembershipsComponent,
canActivate: [roleGuard(['MEMBER'])]
},
{
path: 'admin-memberships',
component: AdminMembershipsComponent,
canActivate: [roleGuard(['ADMIN'])]
},
{
path: 'approvals',
component: AdminUserApprovalComponent,

View File

@ -7,6 +7,9 @@ export interface NavItem {
export const NAV_ITEMS: NavItem[] = [
{label: 'Übersicht', path: '/dashboard', roles: ['ADMIN', 'MEMBER']},
{label: 'Zählpunkte', path: '/dashboard/metering-points', roles: ['ADMIN', 'MEMBER']},
{label: 'Community beitreten', path: '/dashboard/join-community', roles: ['MEMBER']},
{label: 'Meine Mitgliedschaften', path: '/dashboard/my-memberships', roles: ['MEMBER']},
{label: 'Mitgliedschaften', path: '/dashboard/admin-memberships', roles: ['ADMIN']},
{label: 'Mein Profil', path: '/dashboard/profile', roles: ['ADMIN', 'MEMBER']},
{label: 'Benutzerverwaltung', path: '/dashboard/approvals', roles: ['ADMIN']},
{label: 'Energiegemeinschaften', path: '/dashboard/energy-communities', roles: ['ADMIN']}

View File

@ -0,0 +1,46 @@
<div class="max-w-4xl mx-auto p-4 md:p-6">
<h1 class="text-2xl font-bold mb-6">Mitgliedschaftsanträge</h1>
@if (isLoading()) {
<div class="text-center py-8 text-gray-500">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mb-2"></div>
<div>Lade Anträge...</div>
</div>
} @else if (pendingMemberships().length === 0) {
<div class="bg-gray-50 border border-gray-200 rounded-lg p-6 text-center">
<p class="text-gray-600">Keine wartenden Mitgliedschaftsanträge vorhanden.</p>
</div>
} @else {
<div class="space-y-4">
@for (membership of pendingMemberships(); track membership.id) {
<div class="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<span class="font-semibold text-gray-900">{{ membership.firstName }} {{ membership.lastName }}</span>
<span class="text-sm text-gray-500">({{ membership.userEmail }})</span>
</div>
<div class="text-sm text-gray-600">
<span class="font-medium">Community:</span> {{ membership.communityName }} |
<span class="font-medium">Zählpunkt:</span> {{ membership.atNumber }} |
<span class="font-medium">Priorität:</span> {{ membership.priorityLevel }}
</div>
</div>
<div class="flex items-center gap-2">
<button (click)="approveMembership(membership.id)"
[disabled]="isProcessing() === membership.id"
class="bg-green-600 text-white px-4 py-2 rounded-md hover:bg-green-700 disabled:opacity-50 text-sm font-medium">
{{ isProcessing() === membership.id ? '...' : 'Genehmigen' }}
</button>
<button (click)="rejectMembership(membership.id)"
[disabled]="isProcessing() === membership.id"
class="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700 disabled:opacity-50 text-sm font-medium">
{{ isProcessing() === membership.id ? '...' : 'Ablehnen' }}
</button>
</div>
</div>
</div>
}
</div>
}
</div>

View File

@ -0,0 +1,68 @@
import {Component, inject, OnInit, signal} from '@angular/core';
import {MembershipService, PendingMembershipResponse} from '../../services/membership';
import {ToastService} from '../../services/toast';
@Component({
selector: 'app-admin-memberships',
standalone: true,
templateUrl: './admin-memberships.html'
})
export class AdminMembershipsComponent implements OnInit {
private membershipService = inject(MembershipService);
private toastService = inject(ToastService);
pendingMemberships = signal<PendingMembershipResponse[]>([]);
isLoading = signal(true);
isProcessing = signal<string | null>(null);
ngOnInit() {
this.loadPendingMemberships();
}
loadPendingMemberships() {
this.membershipService.getPendingMemberships().subscribe({
next: memberships => {
this.pendingMemberships.set(memberships);
this.isLoading.set(false);
},
error: () => {
this.isLoading.set(false);
this.toastService.error('Fehler beim Laden der wartenden Anträge.');
}
});
}
approveMembership(id: string) {
if (this.isProcessing()) return;
this.isProcessing.set(id);
this.membershipService.approveMembership(id).subscribe({
next: () => {
this.toastService.success('Mitgliedschaft genehmigt.');
this.loadPendingMemberships();
this.isProcessing.set(null);
},
error: (err) => {
this.isProcessing.set(null);
this.toastService.error(err.error?.message || 'Fehler bei der Genehmigung.');
}
});
}
rejectMembership(id: string) {
if (this.isProcessing()) return;
this.isProcessing.set(id);
this.membershipService.rejectMembership(id).subscribe({
next: () => {
this.toastService.success('Mitgliedschaft abgelehnt.');
this.loadPendingMemberships();
this.isProcessing.set(null);
},
error: (err) => {
this.isProcessing.set(null);
this.toastService.error(err.error?.message || 'Fehler bei der Ablehnung.');
}
});
}
}

View File

@ -0,0 +1,75 @@
<div class="max-w-4xl mx-auto p-4 md:p-6">
<h1 class="text-2xl font-bold mb-6">Energiegemeinschaft beitreten</h1>
@if (isLoading()) {
<div class="text-center py-8 text-gray-500">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mb-2"></div>
<div>Lade Zählpunkte...</div>
</div>
} @else if (meteringPoints().length === 0) {
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-6 text-center">
<p class="text-yellow-800">Keine aktiven Zählpunkte vorhanden.</p>
<p class="text-yellow-600 text-sm mt-2">Bitte füge zuerst einen Zählpunkt hinzu und warte auf die Freigabe.</p>
</div>
} @else {
<!-- Zählpunkt auswählen -->
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-lg font-semibold mb-4">1. Zählpunkt auswählen</h2>
<select (change)="onMeteringPointChange($event)"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">-- Zählpunkt wählen --</option>
@for (point of meteringPoints(); track point.id) {
<option [value]="point.id">{{ point.atNumber }} ({{ point.type }})</option>
}
</select>
</div>
<!-- Passende Communities -->
@if (selectedMeteringPoint()) {
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-lg font-semibold mb-4">2. Passende Energiegemeinschaften</h2>
@if (isLoadingCommunities()) {
<div class="text-center py-8 text-gray-500">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mb-2"></div>
<div>Suche passende Communities...</div>
</div>
} @else if (eligibleCommunities().length === 0) {
<div class="bg-gray-50 border border-gray-200 rounded-lg p-6 text-center">
<p class="text-gray-600">Keine passenden Energiegemeinschaften für diesen Zählpunkt gefunden.</p>
</div>
} @else {
<div class="space-y-4">
@for (community of eligibleCommunities(); track community.id) {
<div class="border border-gray-200 rounded-lg p-4 hover:border-blue-300 transition-colors">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h3 class="font-semibold text-gray-900">{{ community.name }}</h3>
<p class="text-sm text-gray-500">{{ community.type }} | EDA-ID: {{ community.edaEcId }}</p>
</div>
<div class="flex items-center gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Priorität</label>
<select [(ngModel)]="selectedPriority"
class="border border-gray-300 rounded-md px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option [value]="1">1</option>
<option [value]="2">2</option>
<option [value]="3">3</option>
<option [value]="4">4</option>
<option [value]="5">5</option>
</select>
</div>
<button (click)="requestMembership(community)" [disabled]="isSubmitting()"
class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50 text-sm">
{{ isSubmitting() ? 'Beantrage...' : 'Beitreten' }}
</button>
</div>
</div>
</div>
}
</div>
}
</div>
}
}
</div>

View File

@ -0,0 +1,97 @@
import {Component, inject, OnInit, signal} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {MeteringPointService, MeteringPoint} from '../../services/metering-point';
import {MembershipService, EligibleCommunity} from '../../services/membership';
import {ToastService} from '../../services/toast';
@Component({
selector: 'app-join-community',
standalone: true,
imports: [FormsModule],
templateUrl: './join-community.html'
})
export class JoinCommunityComponent implements OnInit {
private meteringPointService = inject(MeteringPointService);
private membershipService = inject(MembershipService);
private toastService = inject(ToastService);
meteringPoints = signal<MeteringPoint[]>([]);
selectedMeteringPoint = signal<MeteringPoint | null>(null);
eligibleCommunities = signal<EligibleCommunity[]>([]);
isLoading = signal(true);
isLoadingCommunities = signal(false);
isSubmitting = signal(false);
selectedPriority = signal(1);
ngOnInit() {
this.loadMeteringPoints();
}
loadMeteringPoints() {
this.meteringPointService.getMyMeteringPoints().subscribe({
next: (points: MeteringPoint[]) => {
// Only show ACTIVE metering points
this.meteringPoints.set(points.filter((p: MeteringPoint) => p.makoState === 'ACTIVE'));
this.isLoading.set(false);
},
error: () => this.isLoading.set(false)
});
}
onMeteringPointChange(event: Event) {
const target = event.target as HTMLSelectElement;
const pointId = target.value;
if (!pointId) {
this.selectedMeteringPoint.set(null);
this.eligibleCommunities.set([]);
return;
}
const point = this.meteringPoints().find(p => p.id === pointId);
this.selectedMeteringPoint.set(point || null);
if (point) {
this.loadEligibleCommunities(point.id);
}
}
loadEligibleCommunities(meteringPointId: string) {
this.isLoadingCommunities.set(true);
this.eligibleCommunities.set([]);
this.membershipService.getEligibleCommunities(meteringPointId).subscribe({
next: communities => {
this.eligibleCommunities.set(communities);
this.isLoadingCommunities.set(false);
},
error: () => {
this.isLoadingCommunities.set(false);
this.toastService.error('Fehler beim Laden der passenden Communities.');
}
});
}
requestMembership(community: EligibleCommunity) {
if (this.isSubmitting() || !this.selectedMeteringPoint()) return;
this.isSubmitting.set(true);
this.membershipService.requestMembership({
meteringPointId: this.selectedMeteringPoint()!.id,
energyCommunityId: community.id,
priorityLevel: this.selectedPriority()
}).subscribe({
next: () => {
this.isSubmitting.set(false);
this.toastService.success(`Beitrittsantrag an "${community.name}" erfolgreich gestellt.`);
this.eligibleCommunities.set([]);
this.selectedMeteringPoint.set(null);
},
error: (err) => {
this.isSubmitting.set(false);
this.toastService.error(err.error?.message || 'Fehler beim Stellen des Beitrittsantrags.');
}
});
}
}

View File

@ -0,0 +1,73 @@
<div class="max-w-4xl mx-auto p-4 md:p-6">
<h1 class="text-2xl font-bold mb-6">Meine Mitgliedschaften</h1>
@if (isLoading()) {
<div class="text-center py-8 text-gray-500">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mb-2"></div>
<div>Lade Mitgliedschaften...</div>
</div>
} @else {
<!-- Filter -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">Filter nach Status</label>
<select [(ngModel)]="filterStatus"
class="border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="ALL">Alle</option>
<option value="ACTIVE">Aktiv</option>
<option value="PENDING">Wartend</option>
<option value="INACTIVE">Inaktiv</option>
</select>
</div>
@if (filteredMemberships.length === 0) {
<div class="bg-gray-50 border border-gray-200 rounded-lg p-6 text-center">
<p class="text-gray-600">Keine Mitgliedschaften gefunden.</p>
<a routerLink="/dashboard/join-community"
class="inline-block mt-4 text-blue-600 hover:text-blue-800 font-medium">
Energiegemeinschaft beitreten →
</a>
</div>
} @else {
<!-- Tabelle -->
<div class="bg-white shadow rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Community</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Zählpunkt</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Priorität</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Gültig ab</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@for (membership of filteredMemberships; track membership.id) {
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{{ membership.communityName }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-900">{{ membership.atNumber }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-900">{{ membership.priorityLevel }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full"
[class]="getStatusClass(membership.status)">
{{ getStatusLabel(membership.status) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ membership.validFrom || '-' }}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
</div>

View File

@ -0,0 +1,61 @@
import {Component, inject, OnInit, signal} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {MembershipService, MembershipResponse} from '../../services/membership';
import {ToastService} from '../../services/toast';
@Component({
selector: 'app-my-memberships',
standalone: true,
imports: [FormsModule],
templateUrl: './my-memberships.html'
})
export class MyMembershipsComponent implements OnInit {
private membershipService = inject(MembershipService);
private toastService = inject(ToastService);
memberships = signal<MembershipResponse[]>([]);
isLoading = signal(true);
filterStatus = signal<string>('ALL');
ngOnInit() {
this.loadMemberships();
}
loadMemberships() {
this.membershipService.getOwnMemberships().subscribe({
next: memberships => {
this.memberships.set(memberships);
this.isLoading.set(false);
},
error: () => {
this.isLoading.set(false);
this.toastService.error('Fehler beim Laden der Mitgliedschaften.');
}
});
}
get filteredMemberships(): MembershipResponse[] {
if (this.filterStatus() === 'ALL') {
return this.memberships();
}
return this.memberships().filter(m => m.status === this.filterStatus());
}
getStatusClass(status: string): string {
switch (status) {
case 'ACTIVE': return 'bg-green-100 text-green-800';
case 'PENDING': return 'bg-yellow-100 text-yellow-800';
case 'INACTIVE': return 'bg-gray-100 text-gray-800';
default: return 'bg-gray-100 text-gray-800';
}
}
getStatusLabel(status: string): string {
switch (status) {
case 'ACTIVE': return 'Aktiv';
case 'PENDING': return 'Wartend';
case 'INACTIVE': return 'Inaktiv';
default: return status;
}
}
}

View File

@ -0,0 +1,69 @@
import {inject, Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {Observable} from 'rxjs';
export interface EligibleCommunity {
id: string;
name: string;
type: string;
edaEcId: string;
}
export interface MembershipRequest {
meteringPointId: string;
energyCommunityId: string;
priorityLevel: number;
}
export interface MembershipResponse {
id: string;
meteringPointId: string;
atNumber: string;
energyCommunityId: string;
communityName: string;
priorityLevel: number;
status: string;
validFrom: string | null;
validTo: string | null;
}
export interface PendingMembershipResponse {
id: string;
userEmail: string;
firstName: string;
lastName: string;
atNumber: string;
communityName: string;
priorityLevel: number;
status: string;
}
@Injectable({providedIn: 'root'})
export class MembershipService {
private http = inject(HttpClient);
private apiUrl = 'http://localhost:8080/api/community';
getEligibleCommunities(meteringPointId: string): Observable<EligibleCommunity[]> {
return this.http.get<EligibleCommunity[]>(`${this.apiUrl}/metering-points/${meteringPointId}/eligible-communities`);
}
requestMembership(request: MembershipRequest): Observable<string> {
return this.http.post<string>(`${this.apiUrl}/memberships/request`, request);
}
getOwnMemberships(): Observable<MembershipResponse[]> {
return this.http.get<MembershipResponse[]>(`${this.apiUrl}/memberships/me`);
}
getPendingMemberships(): Observable<PendingMembershipResponse[]> {
return this.http.get<PendingMembershipResponse[]>(`${this.apiUrl}/admin/memberships/pending`);
}
approveMembership(id: string): Observable<void> {
return this.http.put<void>(`${this.apiUrl}/admin/memberships/${id}/approve`, {});
}
rejectMembership(id: string): Observable<void> {
return this.http.put<void>(`${this.apiUrl}/admin/memberships/${id}/reject`, {});
}
}