added basic eg managment

This commit is contained in:
Bernhard Müller 2026-06-23 14:34:06 +02:00
parent ad47f7399e
commit 81ee804ae1
25 changed files with 744 additions and 100 deletions

View File

@ -1,38 +0,0 @@
2026-06-16 18:27:44.502501+02:00 database: close
org.h2.message.DbException: Eingabe/Ausgabe Fehler: "Closing"
IO Exception: "Closing" [90028-240]
at org.h2.message.DbException.get(DbException.java:212)
at org.h2.mvstore.db.Store.close(Store.java:352)
at org.h2.engine.Database.closeOpenFilesAndUnlock(Database.java:1314)
at org.h2.engine.Database.closeImpl(Database.java:1276)
at org.h2.engine.Database.close(Database.java:1195)
at org.h2.engine.Database.onShutdown(Database.java:1180)
at org.h2.engine.OnExitDatabaseCloser.onShutdown(OnExitDatabaseCloser.java:85)
at org.h2.engine.OnExitDatabaseCloser.run(OnExitDatabaseCloser.java:114)
Caused by: org.h2.jdbc.JdbcSQLNonTransientException: Eingabe/Ausgabe Fehler: "Closing"
IO Exception: "Closing" [90028-240]
at org.h2.message.DbException.getJdbcSQLException(DbException.java:566)
at org.h2.message.DbException.getJdbcSQLException(DbException.java:489)
... 8 more
Caused by: org.h2.mvstore.MVStoreException: java.lang.AssertionError: 8 [2.4.240/3]
at org.h2.mvstore.DataUtils.newMVStoreException(DataUtils.java:996)
at org.h2.mvstore.MVStore.executeFilestoreOperation(MVStore.java:1048)
at org.h2.mvstore.RandomAccessStore.compactMoveChunks(RandomAccessStore.java:463)
at org.h2.mvstore.RandomAccessStore.compactStore(RandomAccessStore.java:443)
at org.h2.mvstore.FileStore.compactStore(FileStore.java:891)
at org.h2.mvstore.FileStore.stop(FileStore.java:275)
at org.h2.mvstore.MVStore.closeStore(MVStore.java:803)
at org.h2.mvstore.MVStore.close(MVStore.java:763)
at org.h2.mvstore.db.Store.close(Store.java:348)
... 6 more
Caused by: java.lang.AssertionError: 8
at org.h2.mvstore.FreeSpaceBitSet.allocate(FreeSpaceBitSet.java:153)
at org.h2.mvstore.FreeSpaceBitSet.predictAllocation(FreeSpaceBitSet.java:133)
at org.h2.mvstore.RandomAccessStore.predictAllocation(RandomAccessStore.java:101)
at org.h2.mvstore.RandomAccessStore.moveChunkInside(RandomAccessStore.java:628)
at org.h2.mvstore.RandomAccessStore.compactMoveChunks(RandomAccessStore.java:569)
at org.h2.mvstore.RandomAccessStore.compactMoveChunks(RandomAccessStore.java:481)
at org.h2.mvstore.RandomAccessStore.lambda$compactMoveChunks$1(RandomAccessStore.java:468)
at org.h2.mvstore.FileStore.executeFileStoreOperation(FileStore.java:1894)
at org.h2.mvstore.MVStore.executeFilestoreOperation(MVStore.java:1044)
... 13 more

View File

@ -1,9 +0,0 @@
package at.mueller.eeg.backend.common.event;
import java.util.UUID;
public record MeteringPointApprovedEvent(
UUID userId,
String meteringPointNumber,
String type // "BEZUG" oder "EINSPEISUNG"
) {}

View File

@ -0,0 +1,68 @@
package at.mueller.eeg.backend.community.api;
import at.mueller.eeg.backend.community.domain.EnergyCommunity;
import at.mueller.eeg.backend.community.service.EnergyCommunityService;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/admin/energy-communities")
@RequiredArgsConstructor
public class EnergyCommunityAdminController {
private final EnergyCommunityService service;
// GET /api/admin/energy-communities
@GetMapping
@ApiResponse(
responseCode = "200",
description = "Liste der wartenden Benutzer erfolgreich geladen",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
array = @ArraySchema(
schema = @Schema(implementation = EnergyCommunity.class)
)
)
)
public ResponseEntity<List<EnergyCommunity>> getAllEnergyCommunities() {
return ResponseEntity.ok(service.findAll());
}
// GET /api/admin/energy-communities/{id}
@GetMapping("/{id}")
public ResponseEntity<EnergyCommunity> getEnergyCommunityById(@PathVariable UUID id) {
return ResponseEntity.ok(service.findById(id));
}
// POST /api/admin/energy-communities
@PostMapping
public ResponseEntity<EnergyCommunity> createEnergyCommunity(@RequestBody EnergyCommunity community) {
EnergyCommunity createdCommunity = service.create(community);
return ResponseEntity.status(HttpStatus.CREATED).body(createdCommunity);
}
// PUT /api/admin/energy-communities/{id}
@PutMapping("/{id}")
public ResponseEntity<EnergyCommunity> updateEnergyCommunity(
@PathVariable UUID id,
@RequestBody EnergyCommunity community) {
return ResponseEntity.ok(service.update(id, community));
}
// DELETE /api/admin/energy-communities/{id}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteEnergyCommunity(@PathVariable UUID id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
}

View File

@ -6,6 +6,7 @@ import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@ -28,6 +29,9 @@ public class MeteringPoint {
@Enumerated(EnumType.STRING)
private PointType type;
@Column(name = "consent_requested_at")
private LocalDateTime consentRequestedAt;
@Column(name = "grid_operator_id", length = 50)
private String gridOperatorId;
@ -50,8 +54,17 @@ public class MeteringPoint {
public void fireTrigger(MakoTrigger trigger) {
this.makoState = switch (this.makoState) {
case NEW ->
trigger == MakoTrigger.SEND_REQUEST ? MakoState.WAITING_FOR_CONSENT : invalid(trigger);
case NEW, ERROR -> switch (trigger) {
case SEND_REQUEST -> {
this.consentRequestedAt = LocalDateTime.now();
yield MakoState.WAITING_FOR_CONSENT;
}
case TECHNICAL_FAILURE -> {
this.consentRequestedAt = null;
yield MakoState.ERROR;
}
default -> invalid(trigger);
};
case WAITING_FOR_CONSENT -> switch (trigger) {
case CONSENT_APPROVED -> MakoState.CONSENT_GRANTED;
@ -67,10 +80,6 @@ public class MeteringPoint {
default -> invalid(trigger);
};
// Aus einem Fehlerzustand heraus kann man den Prozess z.B. neu starten
case ERROR ->
trigger == MakoTrigger.SEND_REQUEST ? MakoState.WAITING_FOR_CONSENT : invalid(trigger);
// Endzustände (ACTIVE, REJECTED) erlauben standardmäßig keine weiteren Trigger
default -> invalid(trigger);
};

View File

@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface EnergyCommunityRepository extends JpaRepository<EnergyCommunity, UUID> {
@ -21,4 +22,6 @@ public interface EnergyCommunityRepository extends JpaRepository<EnergyCommunity
@Param("transformerId") String transformerId,
@Param("gridOperatorId") String gridOperatorId
);
Optional<EnergyCommunity> findByEdaEcId(String edaEcId);
}

View File

@ -0,0 +1,53 @@
package at.mueller.eeg.backend.community.service;
import at.mueller.eeg.backend.community.domain.EnergyCommunity;
import at.mueller.eeg.backend.community.repository.EnergyCommunityRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class EnergyCommunityService {
private final EnergyCommunityRepository repository;
public List<EnergyCommunity> findAll() {
return repository.findAll();
}
public EnergyCommunity findById(UUID id) {
return repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Energy Community mit ID " + id + " nicht gefunden."));
}
@Transactional
public EnergyCommunity create(EnergyCommunity community) {
return repository.save(community);
}
@Transactional
public EnergyCommunity update(UUID id, EnergyCommunity updatedData) {
EnergyCommunity existing = findById(id);
existing.setEdaEcId(updatedData.getEdaEcId());
existing.setType(updatedData.getType());
existing.setName(updatedData.getName());
existing.setGridOperatorId(updatedData.getGridOperatorId());
existing.setSubstationId(updatedData.getSubstationId());
existing.setTransformerId(updatedData.getTransformerId());
return repository.save(existing);
}
@Transactional
public void delete(UUID id) {
if (!repository.existsById(id)) {
throw new IllegalArgumentException("Energy Community mit ID " + id + " existiert nicht.");
}
repository.deleteById(id);
}
}

View File

@ -1,11 +1,9 @@
package at.mueller.eeg.backend.iam.api;
import at.mueller.eeg.backend.common.event.UserApprovedEvent;
import at.mueller.eeg.backend.iam.api.dto.IamDtos.UserProfileResponse;
import at.mueller.eeg.backend.iam.domain.RegistrationStatus;
import at.mueller.eeg.backend.iam.domain.User;
import at.mueller.eeg.backend.iam.mapper.UserMapper;
import at.mueller.eeg.backend.iam.repository.UserRepository;
import at.mueller.eeg.backend.iam.service.AdminIamService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
@ -13,7 +11,6 @@ import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@ -31,12 +28,9 @@ import java.util.UUID;
@RequiredArgsConstructor
public class AdminIamController {
private final UserRepository userRepository;
private final AdminIamService adminIamService;
private final UserMapper userMapper;
private final ApplicationEventPublisher eventPublisher;
@GetMapping("/pending")
@Operation(summary = "Lädt alle wartenden User",
description = "Zeigt User im Status PENDING inkl. der Ergebnisse des EDA-Netz-Checks.")
@ -51,7 +45,7 @@ public class AdminIamController {
)
)
public ResponseEntity<List<UserProfileResponse>> getPendingUsers() {
List<User> allByStatus = userRepository.findAllByStatus(RegistrationStatus.PENDING);
List<User> allByStatus = adminIamService.getPendingUsers();
return ResponseEntity.ok(userMapper.mapToDto(allByStatus));
}
@ -59,23 +53,14 @@ public class AdminIamController {
@Operation(summary = "User freigeben",
description = "Setzt enabled=true, status=APPROVED und triggert die finale EDA-Anmeldung.")
public ResponseEntity<Void> approveUser(@PathVariable UUID userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found"));
if (!RegistrationStatus.PENDING.equals(user.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "User is not in PENDING status");
}
if (user.isEnabled()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "User is already enabled");
}
user.setStatus(RegistrationStatus.APPROVED);
user.setEnabled(true);
userRepository.save(user);
eventPublisher.publishEvent(new UserApprovedEvent(user.getId()));
try {
adminIamService.approveUser(userId);
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, e.getMessage());
} catch (IllegalStateException e) {
throw new ResponseStatusException(HttpStatus.CONFLICT, e.getMessage());
}
}
@PostMapping("/{userId}/reject")
@ -85,6 +70,7 @@ public class AdminIamController {
// Logik: status auf REJECTED setzen oder User hard-löschen, je nach Datenschutz-Konzept
// Event feuern: UserRejectedEvent
adminIamService.rejectUser(userId);
return ResponseEntity.noContent().build();
}
}

View File

@ -0,0 +1,49 @@
package at.mueller.eeg.backend.iam.service;
import at.mueller.eeg.backend.common.event.UserApprovedEvent;
import at.mueller.eeg.backend.iam.domain.RegistrationStatus;
import at.mueller.eeg.backend.iam.domain.User;
import at.mueller.eeg.backend.iam.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class AdminIamService {
private final UserRepository userRepository;
private final ApplicationEventPublisher eventPublisher;
public List<User> getPendingUsers() {
return userRepository.findAllByStatus(RegistrationStatus.PENDING);
}
@Transactional
public void approveUser(UUID userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User nicht gefunden"));
if (!RegistrationStatus.PENDING.equals(user.getStatus())) {
throw new IllegalStateException("User ist nicht im Status PENDING");
}
if (user.isEnabled()) {
throw new IllegalStateException("User ist bereits aktiviert");
}
user.setStatus(RegistrationStatus.APPROVED);
user.setEnabled(true);
userRepository.save(user);
eventPublisher.publishEvent(new UserApprovedEvent(user.getId()));
}
@Transactional
public void rejectUser(UUID userId) {
// Hier folgt später die Logik für das Ablehnen
}
}

View File

@ -1,9 +1,8 @@
package at.mueller.eeg.backend.mako.event.listener;
import at.mueller.eeg.backend.common.event.EdaTopologyCheckEvent;
import at.mueller.eeg.backend.common.event.EdaTopologyCompletedEvent;
import at.mueller.eeg.backend.common.event.EdaTopologyFailedEvent;
import at.mueller.eeg.backend.common.event.*;
import at.mueller.eeg.backend.mako.api.dto.EdaTopologyResponse;
import at.mueller.eeg.backend.mako.service.EdaConsentService;
import at.mueller.eeg.backend.mako.service.EdaTopologyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -19,6 +18,7 @@ import org.springframework.stereotype.Component;
public class EdaCommuncationEventListener {
private final EdaTopologyService edaTopologyService;
private final EdaConsentService edaConsentService;
private final ApplicationEventPublisher eventPublisher;
@EventListener
@ -51,4 +51,30 @@ public class EdaCommuncationEventListener {
));
}
}
@EventListener
@Async
public void handleInitiateConsentEvent(InitiateMakoConsentEvent event) {
log.info("MaKo-Domain: Asynchroner Consent-Request gestartet für AT-Nummer: {} (Typ: {})",
event.atNumber(), event.pointType());
try {
edaConsentService.sendConsentRequest(event.atNumber(), event.pointType());
log.info("MaKo-Domain: Consent-Request erfolgreich an EDA übermittelt für {}", event.atNumber());
// Da der Netzbetreiber jetzt asynchron antwortet (entweder via Webhook oder Mailbox),
// endet der MaKo-Job hier vorerst. Die Community-Domain wartet im Status WAITING_FOR_CONSENT.
} catch (Exception e) {
log.error("MaKo-Domain: Fehler beim Senden des Consent-Requests für AT-Nummer {}: {}",
event.atNumber(), e.getMessage());
eventPublisher.publishEvent(new MakoConsensTechnicalFailedEvent(
event.atNumber(),
e.getMessage()
));
}
}
}

View File

@ -1,4 +0,0 @@
package at.mueller.eeg.backend.mako.event.listener;
public class MakoCommandListener {
}

View File

@ -41,6 +41,63 @@ paths:
responses:
"200":
description: OK
/api/admin/energy-communities/{id}:
get:
tags:
- energy-community-admin-controller
operationId: getEnergyCommunityById
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: OK
content:
'*/*':
schema:
$ref: "#/components/schemas/EnergyCommunity"
put:
tags:
- energy-community-admin-controller
operationId: updateEnergyCommunity
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/EnergyCommunity"
required: true
responses:
"200":
description: OK
content:
'*/*':
schema:
$ref: "#/components/schemas/EnergyCommunity"
delete:
tags:
- energy-community-admin-controller
operationId: deleteEnergyCommunity
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: OK
/api/community/memberships/request:
post:
tags:
@ -132,6 +189,37 @@ paths:
responses:
"200":
description: OK
/api/admin/energy-communities:
get:
tags:
- energy-community-admin-controller
operationId: getAllEnergyCommunities
responses:
"200":
description: Liste der wartenden Benutzer erfolgreich geladen
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/EnergyCommunity"
post:
tags:
- energy-community-admin-controller
operationId: createEnergyCommunity
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/EnergyCommunity"
required: true
responses:
"200":
description: OK
content:
'*/*':
schema:
$ref: "#/components/schemas/EnergyCommunity"
/api/community/metering-points/{id}/eligible-communities:
get:
tags:
@ -171,6 +259,28 @@ paths:
$ref: "#/components/schemas/UserProfileResponse"
components:
schemas:
EnergyCommunity:
type: object
properties:
id:
type: string
format: uuid
edaEcId:
type: string
type:
type: string
enum:
- EEG_LOKAL
- EEG_REGIONAL
- BEG
name:
type: string
gridOperatorId:
type: string
substationId:
type: string
transformerId:
type: string
MembershipRequest:
type: object
properties:

View File

@ -1,6 +1,7 @@
<!-- Der Header bleibt immer fix oben stehen -->
@if (!isAdminRoute) {
<app-header></app-header>
}
<!-- Hier drin werden je nach Route die Login-, Register- oder Dashboard-Seiten geladen -->
<main class="min-h-[calc(100vh-4rem)] bg-gray-50">
<router-outlet></router-outlet>

View File

@ -4,14 +4,34 @@ import {Register} from './pages/register/register';
import {LoginComponent} from './pages/login/login';
import {AdminUserApprovalComponent} from './pages/admin-user-approval/admin-user-approval';
import {adminAuthGuard} from './guards/admin-auth-guard';
import {AppLayout} from './layout/app-layout/app-layout';
import {AdminEnergyCommunity} from './pages/admin-energy-community/admin-energy-community';
export const routes: Routes = [
{ path: '', component: LandingPage },
{ path: 'register', component: Register },
{ path: 'login', component: LoginComponent},
{
path: 'admin/approvals',
component: AdminUserApprovalComponent,
canActivate: [adminAuthGuard]
path: 'admin',
component: AppLayout,
canActivate: [adminAuthGuard],
children: [
{
path: '',
redirectTo: 'approvals',
pathMatch: 'full'
},
{
path: 'approvals',
component: AdminUserApprovalComponent
},
{
path: 'energy-communities',
component: AdminEnergyCommunity
}
]
},
// Wildcard-Fallback für unbekannte URLs
{ path: '**', redirectTo: '' }
];

View File

@ -1,5 +1,5 @@
import {Component, signal} from '@angular/core';
import {RouterOutlet} from '@angular/router';
import {NavigationEnd, Router, RouterOutlet} from '@angular/router';
import {HeaderComponent} from './layout/header/header';
@Component({
@ -10,4 +10,14 @@ import {HeaderComponent} from './layout/header/header';
})
export class App {
protected readonly title = signal('eeg_frontend');
isAdminRoute = false;
constructor(private router: Router) {
this.router.events.subscribe(event => {
if (event instanceof NavigationEnd) {
this.isAdminRoute = event.urlAfterRedirects.startsWith('/admin');
}
});
}
}

View File

@ -1,6 +1,6 @@
import {inject} from '@angular/core';
import {CanActivateFn, Router} from '@angular/router';
import {AuthService} from '../services/auth'; // Adjust path if necessary
import {AuthService} from '../services/auth';
export const adminAuthGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService);
@ -8,7 +8,7 @@ export const adminAuthGuard: CanActivateFn = (route, state) => {
// Replace these with your actual authentication and role-checking methods
const isAuthenticated = authService.isLoggedIn();
const isAdmin = authService.getUserRole() === 'ADMIN';
const isAdmin = authService.currentUserRole() === 'ADMIN';
if (isAuthenticated && isAdmin) {
return true;
}

View File

@ -0,0 +1,47 @@
<div class="flex h-screen bg-slate-50">
<aside class="w-64 bg-white border-r border-slate-200 flex flex-col z-10 shrink-0">
<div class="h-16 flex items-center px-6 border-b border-slate-200 shrink-0">
<a routerLink="/" class="flex items-center space-x-2 group">
<svg class="h-7 w-7 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span class="font-bold text-xl text-slate-900 tracking-tight">
eeg<span class="text-emerald-600">portal</span>
</span>
</a>
</div>
<nav class="flex-1 px-4 py-6 space-y-1 overflow-y-auto">
<a routerLink="/admin/approvals" routerLinkActive="bg-emerald-50 text-emerald-700 font-medium"
class="block px-4 py-2.5 rounded-lg text-slate-600 hover:bg-slate-50 hover:text-slate-900 transition-colors">
Benutzerverwaltung
</a>
<a routerLink="/admin/energy-communities" routerLinkActive="bg-emerald-50 text-emerald-700 font-medium"
class="block px-4 py-2.5 rounded-lg text-slate-600 hover:bg-slate-50 hover:text-slate-900 transition-colors">
Energiegemeinschaften
</a>
</nav>
</aside>
<main class="flex-1 flex flex-col min-w-0 overflow-hidden">
<header class="h-16 bg-white border-b border-slate-200 flex items-center px-8 justify-between shrink-0">
<h1 class="text-lg font-semibold text-slate-800">Verwaltung</h1>
<div class="flex items-center gap-2 text-sm text-slate-600 bg-slate-50 px-3 py-1.5 rounded-full border border-slate-200">
<span class="relative flex h-2.5 w-2.5">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
</span>
Admin
</div>
</header>
<div class="flex-1 overflow-auto p-8">
<router-outlet></router-outlet>
</div>
</main>
</div>

View File

@ -0,0 +1,22 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {AppLayout} from './app-layout';
describe('AdminLayout', () => {
let component: AppLayout;
let fixture: ComponentFixture<AppLayout>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppLayout],
}).compileComponents();
fixture = TestBed.createComponent(AppLayout);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,11 @@
import {Component} from '@angular/core';
import {RouterLink, RouterLinkActive, RouterOutlet} from '@angular/router';
@Component({
selector: 'app-admin-layout',
standalone: true,
imports: [RouterOutlet, RouterLink, RouterLinkActive],
templateUrl: './app-layout.html',
styleUrl: './app-layout.scss'
})
export class AppLayout {}

View File

@ -25,7 +25,7 @@
@if (authService.isLoggedIn$ | async) {
<span class="text-sm text-gray-500 hidden sm:inline">
Rolle: <span class="font-semibold text-gray-700">{{ authService.getUserRole() }}</span>
Rolle: <span class="font-semibold text-gray-700">{{ authService.currentUserRole() }}</span>
</span>
<button (click)="onLogout()" class="text-sm font-medium text-red-600 hover:text-red-700 border border-red-200 hover:border-red-300 px-3 py-1.5 rounded-md transition duration-150">
Abmelden

View File

@ -0,0 +1,142 @@
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 gap-4">
<div>
<h2 class="text-2xl font-bold text-slate-800">Energiegemeinschaften</h2>
<p class="text-sm text-slate-500 mt-1">Verwalte alle EGs und deren Netz-Zuordnungen.</p>
</div>
<button (click)="openCreateModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2.5 rounded-xl font-medium transition-colors shadow-sm">
+ Neue EG anlegen
</button>
</div>
<div class="overflow-x-auto ring-1 ring-slate-200 rounded-xl">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-slate-50 text-sm text-slate-500 border-b border-slate-200">
<th class="py-4 px-6 font-semibold">Name</th>
<th class="py-4 px-6 font-semibold">EDA ID</th>
<th class="py-4 px-6 font-semibold">Typ</th>
<th class="py-4 px-6 font-semibold">Netzbetreiber</th>
<th class="py-4 px-6 font-semibold text-right">Aktionen</th>
</tr>
</thead>
<tbody class="text-sm divide-y divide-slate-100">
@for (ec of communities; track ec.id) {
<tr class="hover:bg-slate-50/50 transition-colors">
<td class="py-4 px-6 font-medium text-slate-900">{{ ec.name }}</td>
<td class="py-4 px-6 text-slate-600 font-mono text-xs">{{ ec.edaEcId }}</td>
<td class="py-4 px-6 text-slate-600">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700">
{{ ec.type }}
</span>
</td>
<td class="py-4 px-6 text-slate-600">{{ ec.gridOperatorId || '-' }}</td>
<td class="py-4 px-6 text-right space-x-3">
<button (click)="openEditModal(ec)" class="text-blue-600 hover:text-blue-800 font-medium transition-colors">
Bearbeiten
</button>
<button (click)="onDelete(ec.id)"
[disabled]="!canDelete(ec)"
[class.opacity-40]="!canDelete(ec)"
class="text-red-600 hover:text-red-800 disabled:hover:text-red-600 font-medium transition-colors">
Löschen
</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="py-12 text-center text-slate-500">
<div class="flex flex-col items-center justify-center">
<span class="text-lg font-medium text-slate-600">Keine Daten gefunden</span>
<span class="text-sm mt-1">Es wurden noch keine Energiegemeinschaften angelegt.</span>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
@if (isModalOpen) {
<div class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 backdrop-blur-sm transition-opacity">
<div class="bg-white rounded-2xl shadow-xl w-full max-w-lg overflow-hidden flex flex-col max-h-[90vh]">
<div class="px-6 py-4 border-b border-slate-200 flex justify-between items-center bg-slate-50 shrink-0">
<h3 class="text-lg font-bold text-slate-800">Neue Energiegemeinschaft anlegen</h3>
<button (click)="closeModal()" class="text-slate-400 hover:text-slate-600 transition-colors">
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="p-6 overflow-y-auto">
<form [formGroup]="createForm" (ngSubmit)="onSubmit()" id="createEcForm" class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Name der Gemeinschaft</label>
<input type="text" formControlName="name"
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">EDA ID</label>
<input type="text" formControlName="edaEcId" placeholder="z.B. AT..."
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Typ</label>
<select formControlName="type"
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all bg-white">
<option value="" disabled selected>Bitte wählen</option>
<option value="BEG">BEG (Bürgerenergiegemeinschaft)</option>
<option value="EEG_REGIONAL">Lokale EEG (Lokale Erneuerbare-Energie-Gemeinschaft)</option>
<option value="EEG_LOKAL">Lokale EEG (Lokale Erneuerbare-Energie-Gemeinschaft)</option>
</select>
</div>
</div>
<div class="border-t border-slate-200 my-4 pt-4">
<h4 class="text-sm font-semibold text-slate-800 mb-3">Netz-Topologie (Optional)</h4>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Netzbetreiber ID</label>
<input type="text" formControlName="gridOperatorId"
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Umspannwerk ID</label>
<input type="text" formControlName="substationId"
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Transformator ID</label>
<input type="text" formControlName="transformerId"
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all">
</div>
</div>
</div>
</div>
</form>
</div>
<div class="px-6 py-4 border-t border-slate-200 bg-slate-50 flex justify-end gap-3 shrink-0">
<button type="button" (click)="closeModal()" class="px-5 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-200 rounded-xl transition-colors">
Abbrechen
</button>
<button type="submit" form="createEcForm" [disabled]="!createForm.valid"
class="px-5 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-colors shadow-sm">
Speichern
</button>
</div>
</div>
</div>
}
</div>

View File

@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AdminEnergyCommunity } from './admin-energy-community';
describe('AdminEnergyCommunity', () => {
let component: AdminEnergyCommunity;
let fixture: ComponentFixture<AdminEnergyCommunity>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AdminEnergyCommunity],
}).compileComponents();
fixture = TestBed.createComponent(AdminEnergyCommunity);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,116 @@
import {Component, inject, OnInit} from '@angular/core';
import {CommonModule} from '@angular/common';
import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms';
import {EnergyCommunity, EnergyCommunityAdminControllerService} from '../../api';
@Component({
selector: 'app-admin-energy-community',
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './admin-energy-community.html',
styleUrl: './admin-energy-community.scss',
})
export class AdminEnergyCommunity implements OnInit {
private ecService = inject(EnergyCommunityAdminControllerService);
private fb = inject(FormBuilder);
communities: EnergyCommunity[] = [];
isModalOpen = false;
isEditMode = false;
editingId: string | null = null;
createForm = this.fb.group({
name: ['', Validators.required],
edaEcId: ['', Validators.required],
type: ['', Validators.required],
gridOperatorId: [''],
substationId: [''],
transformerId: ['']
});
ngOnInit() {
this.loadCommunities();
}
loadCommunities() {
this.ecService.getAllEnergyCommunities().subscribe({
next: (data) => {
this.communities = data;
},
error: (err) => {
console.error('Fehler beim Laden der Energiegemeinschaften', err);
// Hier würde später ein globaler Error-Toast getriggert werden
}
});
}
openCreateModal(): void {
this.isEditMode = false;
this.editingId = null;
this.createForm.reset();
this.createForm.get('edaEcId')?.enable();
this.createForm.get('type')?.enable();
this.isModalOpen = true;
}
openEditModal(ec: EnergyCommunity): void {
this.isEditMode = true;
this.editingId = ec.id ?? null;
this.createForm.patchValue(ec as any);
this.createForm.get('edaEcId')?.disable();
this.createForm.get('type')?.disable();
this.isModalOpen = true;
}
closeModal(): void {
this.isModalOpen = false;
this.createForm.reset();
}
onSubmit(): void {
if (this.createForm.valid) {
const formData = this.createForm.getRawValue();
if (this.isEditMode && this.editingId) {
this.ecService.updateEnergyCommunity(this.editingId, formData as any).subscribe({
next: (updatedEc) => {
const index = this.communities.findIndex(c => c.id === this.editingId);
if (index !== -1) this.communities[index] = updatedEc;
this.closeModal();
},
error: (err) => console.error('Fehler beim Updaten', err)
});
} else {
this.ecService.createEnergyCommunity(formData as any).subscribe({
next: (newCommunity) => {
this.communities.push(newCommunity);
this.closeModal();
},
error: (err) => console.error('Fehler beim Erstellen', err)
});
}
} else {
this.createForm.markAllAsTouched();
}
}
canDelete(ec: EnergyCommunity): boolean {
// Wenn dein Backend z.B. die Anzahl der Mitglieder mitliefert:
// return !(ec as any).memberCount || (ec as any).memberCount === 0;
return true; // Vorerst true, bis die Relation im DTO steht
}
onDelete(id: string | undefined): void {
if (!id) return;
if (confirm('Möchtest du diese Energiegemeinschaft wirklich löschen?')) {
this.ecService.deleteEnergyCommunity(id).subscribe({
next: () => {
this.communities = this.communities.filter(c => c.id !== id);
},
error: (err) => console.error('Löschen fehlgeschlagen', err)
});
}
}
}

View File

@ -1,4 +1,4 @@
import {inject, Injectable} from '@angular/core';
import {inject, Injectable, signal} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {BehaviorSubject, Observable, tap} from 'rxjs';
import {AuthResponse} from '../api';
@ -16,8 +16,9 @@ export class AuthService {
isLoggedIn$ = this.isLoggedInSubject.asObservable();
currentUserRole = signal<string | null>(localStorage.getItem('user_role'));
register(userData: any): Observable<any> {
// Sendet die Daten als JSON-Body an den Endpunkt /register
return this.http.post(`${this.apiUrl}/register`, userData);
}
@ -27,13 +28,12 @@ export class AuthService {
// Token und Rolle im LocalStorage (oder SessionStorage) ablegen
localStorage.setItem('token', response.token!);
localStorage.setItem('user_role', response.role!);
this.isLoggedInSubject.next(true);
this.currentUserRole.set(response.role!);
})
);
}
getUserRole(): string | null {
return localStorage.getItem('user_role');
}
isLoggedIn(): boolean {
return !!localStorage.getItem('token');