feat(community): add member consumption dashboard with coverage preview

Backend:
- ConsumptionDashboardService: aggregates consumption and community
  feed-in data with daily/hourly granularity based on date range
- Coverage calculation: min(consumption, communityFeedIn) per interval
- Ownership check on dashboard endpoint
- 6 new aggregate queries in MeteringDataRepository
- findActiveMeteringPointIdsByCommunityId in MembershipRepository
- 6 unit tests for ConsumptionDashboardService

Frontend:
- Apache ECharts via ngx-echarts for chart visualization
- ConsumptionDashboardComponent with metering point selector,
  date range picker, quick-range buttons (7/30/90 days)
- Chart: blue bars (consumption), green bars (covered), orange
  line (community feed-in), tooltip with coverage percentage
- Summary cards: total consumption, coverage %, feed-in, covered kWh
- Warning when user has no community membership
- Route /dashboard/consumption (MEMBER only)

Tests: 143/143 passing
This commit is contained in:
Bernhard Müller 2026-07-22 13:23:58 +02:00
parent 975f76dbd3
commit 4b7b26231f
14 changed files with 920 additions and 0 deletions

View File

@ -0,0 +1,32 @@
package at.mueller.eeg.backend.community.api;
import at.mueller.eeg.backend.common.security.CurrentUserId;
import at.mueller.eeg.backend.community.api.dto.ConsumptionDashboardDtos.ConsumptionDashboardResponse;
import at.mueller.eeg.backend.community.service.ConsumptionDashboardService;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.UUID;
@RestController
@RequestMapping("/api/community/consumption")
@RequiredArgsConstructor
public class ConsumptionDashboardController {
private final ConsumptionDashboardService consumptionDashboardService;
@GetMapping("/{meteringPointId}/dashboard")
@PreAuthorize("hasRole('MEMBER')")
public ResponseEntity<ConsumptionDashboardResponse> getDashboard(
@CurrentUserId String userId,
@PathVariable UUID meteringPointId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to) {
return ResponseEntity.ok(consumptionDashboardService.getDashboard(
UUID.fromString(userId), meteringPointId, from, to));
}
}

View File

@ -0,0 +1,35 @@
package at.mueller.eeg.backend.community.api.dto;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
public interface ConsumptionDashboardDtos {
record IntervalData(
LocalDateTime timestamp,
double consumptionKwh,
double communityFeedInKwh,
double coveredKwh,
double coveragePercent
) {}
record ConsumptionDashboardResponse(
UUID meteringPointId,
String atNumber,
UUID energyCommunityId,
String communityName,
LocalDateTime from,
LocalDateTime to,
String granularity,
List<IntervalData> data,
Summary summary
) {}
record Summary(
double totalConsumptionKwh,
double totalCoveredKwh,
double totalCommunityFeedInKwh,
double averageCoveragePercent
) {}
}

View File

@ -42,4 +42,7 @@ public interface MembershipRepository extends JpaRepository<Membership, UUID> {
@Query("SELECT COUNT(m) > 0 FROM Membership m JOIN m.meteringPoint mp WHERE mp.userId = :userId AND m.energyCommunity.id = :communityId AND m.status != 'INACTIVE'")
boolean isActiveMemberOfCommunity(@Param("userId") UUID userId, @Param("communityId") UUID communityId);
@Query("SELECT mp.id FROM Membership m JOIN m.meteringPoint mp WHERE m.energyCommunity.id = :communityId AND m.status = 'ACTIVE'")
List<UUID> findActiveMeteringPointIdsByCommunityId(@Param("communityId") UUID communityId);
}

View File

@ -3,6 +3,8 @@ package at.mueller.eeg.backend.community.repository;
import at.mueller.eeg.backend.community.domain.MeteringData;
import at.mueller.eeg.backend.community.domain.MeteringDataType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
@ -20,4 +22,40 @@ public interface MeteringDataRepository extends JpaRepository<MeteringData, UUID
List<MeteringData> findByMeteringPointIdAndDataTypeAndIntervalStart(
UUID meteringPointId, MeteringDataType dataType, LocalDateTime intervalStart);
@Query("SELECT FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('YEAR', m.intervalStart), SUM(m.kwh) " +
"FROM MeteringData m WHERE m.meteringPoint.id = :meteringPointId AND m.dataType = 'CONSUMPTION' " +
"AND m.intervalStart BETWEEN :from AND :to " +
"GROUP BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart) " +
"ORDER BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart)")
List<Object[]> aggregateConsumptionByDay(@Param("meteringPointId") UUID meteringPointId,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to);
@Query("SELECT FUNCTION('HOUR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('YEAR', m.intervalStart), SUM(m.kwh) " +
"FROM MeteringData m WHERE m.meteringPoint.id = :meteringPointId AND m.dataType = 'CONSUMPTION' " +
"AND m.intervalStart BETWEEN :from AND :to " +
"GROUP BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('HOUR', m.intervalStart) " +
"ORDER BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('HOUR', m.intervalStart)")
List<Object[]> aggregateConsumptionByHour(@Param("meteringPointId") UUID meteringPointId,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to);
@Query("SELECT FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('YEAR', m.intervalStart), SUM(m.kwh) " +
"FROM MeteringData m WHERE m.meteringPoint.id IN :meteringPointIds AND m.dataType = 'FEED_IN' " +
"AND m.intervalStart BETWEEN :from AND :to " +
"GROUP BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart) " +
"ORDER BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart)")
List<Object[]> aggregateFeedInByDay(@Param("meteringPointIds") List<UUID> meteringPointIds,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to);
@Query("SELECT FUNCTION('HOUR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('YEAR', m.intervalStart), SUM(m.kwh) " +
"FROM MeteringData m WHERE m.meteringPoint.id IN :meteringPointIds AND m.dataType = 'FEED_IN' " +
"AND m.intervalStart BETWEEN :from AND :to " +
"GROUP BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('HOUR', m.intervalStart) " +
"ORDER BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('HOUR', m.intervalStart)")
List<Object[]> aggregateFeedInByHour(@Param("meteringPointIds") List<UUID> meteringPointIds,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to);
}

View File

@ -0,0 +1,141 @@
package at.mueller.eeg.backend.community.service;
import at.mueller.eeg.backend.community.api.dto.ConsumptionDashboardDtos;
import at.mueller.eeg.backend.community.api.dto.ConsumptionDashboardDtos.ConsumptionDashboardResponse;
import at.mueller.eeg.backend.community.api.dto.ConsumptionDashboardDtos.IntervalData;
import at.mueller.eeg.backend.community.api.dto.ConsumptionDashboardDtos.Summary;
import at.mueller.eeg.backend.community.domain.MeteringPoint;
import at.mueller.eeg.backend.community.domain.Membership;
import at.mueller.eeg.backend.community.repository.MembershipRepository;
import at.mueller.eeg.backend.community.repository.MeteringDataRepository;
import at.mueller.eeg.backend.community.repository.MeteringPointRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ConsumptionDashboardService {
private final MeteringDataRepository meteringDataRepository;
private final MeteringPointRepository meteringPointRepository;
private final MembershipRepository membershipRepository;
public ConsumptionDashboardResponse getDashboard(UUID userId, UUID meteringPointId,
LocalDateTime from, LocalDateTime to) {
MeteringPoint mp = verifyOwnership(userId, meteringPointId);
Optional<Membership> membership = membershipRepository.findByMeteringPointId(meteringPointId)
.stream()
.filter(m -> m.getStatus() == at.mueller.eeg.backend.community.domain.MembershipStatus.ACTIVE)
.findFirst();
UUID communityId = membership.map(m -> m.getEnergyCommunity().getId()).orElse(null);
String communityName = membership.map(m -> m.getEnergyCommunity().getName()).orElse(null);
Duration range = Duration.between(from, to);
String granularity;
List<Object[]> rawConsumption;
List<Object[]> rawFeedIn;
if (range.toHours() <= 24) {
granularity = "HOURLY";
rawConsumption = meteringDataRepository.aggregateConsumptionByHour(meteringPointId, from, to);
if (communityId != null) {
List<UUID> producerIds = membershipRepository.findActiveMeteringPointIdsByCommunityId(communityId);
rawFeedIn = producerIds.isEmpty() ? List.of() :
meteringDataRepository.aggregateFeedInByHour(producerIds, from, to);
} else {
rawFeedIn = List.of();
}
} else {
granularity = "DAILY";
rawConsumption = meteringDataRepository.aggregateConsumptionByDay(meteringPointId, from, to);
if (communityId != null) {
List<UUID> producerIds = membershipRepository.findActiveMeteringPointIdsByCommunityId(communityId);
rawFeedIn = producerIds.isEmpty() ? List.of() :
meteringDataRepository.aggregateFeedInByDay(producerIds, from, to);
} else {
rawFeedIn = List.of();
}
}
Map<LocalDateTime, Double> consumptionMap;
Map<LocalDateTime, Double> feedInMap;
if ("HOURLY".equals(granularity)) {
consumptionMap = parseHourlyMap(rawConsumption);
feedInMap = parseHourlyMap(rawFeedIn);
} else {
consumptionMap = parseDailyMap(rawConsumption);
feedInMap = parseDailyMap(rawFeedIn);
}
Set<LocalDateTime> allTimestamps = new TreeSet<>(consumptionMap.keySet());
allTimestamps.addAll(feedInMap.keySet());
List<IntervalData> data = allTimestamps.stream()
.map(ts -> {
double consumption = consumptionMap.getOrDefault(ts, 0.0);
double feedIn = feedInMap.getOrDefault(ts, 0.0);
double covered = Math.min(consumption, feedIn);
double coveragePct = consumption > 0 ? (covered / consumption * 100) : 0.0;
return new IntervalData(ts, consumption, feedIn, covered, coveragePct);
})
.toList();
double totalConsumption = data.stream().mapToDouble(IntervalData::consumptionKwh).sum();
double totalCovered = data.stream().mapToDouble(IntervalData::coveredKwh).sum();
double totalFeedIn = data.stream().mapToDouble(IntervalData::communityFeedInKwh).sum();
double avgCoverage = totalConsumption > 0 ? (totalCovered / totalConsumption * 100) : 0.0;
Summary summary = new Summary(totalConsumption, totalCovered, totalFeedIn, avgCoverage);
return new ConsumptionDashboardResponse(
meteringPointId, mp.getAtNumber(), communityId, communityName,
from, to, granularity, data, summary);
}
private MeteringPoint verifyOwnership(UUID userId, UUID meteringPointId) {
return meteringPointRepository.findById(meteringPointId)
.filter(mp -> mp.getUserId().equals(userId))
.orElseThrow(() -> new AccessDeniedException(
"Keine Berechtigung zum Zugriff auf diesen Zählpunkt"));
}
private Map<LocalDateTime, Double> parseDailyMap(List<Object[]> rows) {
Map<LocalDateTime, Double> result = new TreeMap<>();
for (Object[] row : rows) {
int dayOfYear = ((Number) row[0]).intValue();
int year = ((Number) row[1]).intValue();
double kwh = ((Number) row[2]).doubleValue();
LocalDateTime ts = LocalDateTime.of(year, 1, 1, 0, 0).plusDays(dayOfYear - 1);
result.merge(ts, kwh, Double::sum);
}
return result;
}
private Map<LocalDateTime, Double> parseHourlyMap(List<Object[]> rows) {
Map<LocalDateTime, Double> result = new TreeMap<>();
for (Object[] row : rows) {
int hour = ((Number) row[0]).intValue();
int dayOfYear = ((Number) row[1]).intValue();
int year = ((Number) row[2]).intValue();
double kwh = ((Number) row[3]).doubleValue();
LocalDateTime ts = LocalDateTime.of(year, 1, 1, 0, 0)
.plusDays(dayOfYear - 1)
.plusHours(hour);
result.merge(ts, kwh, Double::sum);
}
return result;
}
}

View File

@ -0,0 +1,217 @@
package at.mueller.eeg.backend.community.service;
import at.mueller.eeg.backend.community.api.dto.ConsumptionDashboardDtos.ConsumptionDashboardResponse;
import at.mueller.eeg.backend.community.domain.*;
import at.mueller.eeg.backend.community.domain.state.MakoState;
import at.mueller.eeg.backend.community.repository.MembershipRepository;
import at.mueller.eeg.backend.community.repository.MeteringDataRepository;
import at.mueller.eeg.backend.community.repository.MeteringPointRepository;
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 org.springframework.security.access.AccessDeniedException;
import java.time.LocalDateTime;
import java.util.ArrayList;
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.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ConsumptionDashboardServiceTest {
@Mock
private MeteringDataRepository meteringDataRepository;
@Mock
private MeteringPointRepository meteringPointRepository;
@Mock
private MembershipRepository membershipRepository;
@InjectMocks
private ConsumptionDashboardService service;
private UUID userId;
private UUID meteringPointId;
private UUID communityId;
private MeteringPoint meteringPoint;
private EnergyCommunity community;
private Membership membership;
@BeforeEach
void setUp() {
userId = UUID.randomUUID();
meteringPointId = UUID.randomUUID();
communityId = UUID.randomUUID();
meteringPoint = new MeteringPoint();
meteringPoint.setId(meteringPointId);
meteringPoint.setUserId(userId);
meteringPoint.setAtNumber("AT0010000000000000000000001234567");
meteringPoint.setMakoState(MakoState.ACTIVE);
community = new EnergyCommunity();
community.setId(communityId);
community.setName("Test-EEG");
membership = new Membership();
membership.setMeteringPoint(meteringPoint);
membership.setEnergyCommunity(community);
membership.setStatus(MembershipStatus.ACTIVE);
}
@SuppressWarnings("unchecked")
private List<Object[]> dailyRows(Object[]... rows) {
List<Object[]> list = new ArrayList<>();
for (Object[] row : rows) {
list.add(row);
}
return list;
}
@SuppressWarnings("unchecked")
private List<Object[]> hourlyRows(Object[]... rows) {
List<Object[]> list = new ArrayList<>();
for (Object[] row : rows) {
list.add(row);
}
return list;
}
@Test
void getDashboard_returnsDataWithCoverage() {
when(meteringPointRepository.findById(meteringPointId)).thenReturn(Optional.of(meteringPoint));
when(membershipRepository.findByMeteringPointId(meteringPointId)).thenReturn(List.of(membership));
when(membershipRepository.findActiveMeteringPointIdsByCommunityId(communityId)).thenReturn(List.of(UUID.randomUUID()));
LocalDateTime from = LocalDateTime.of(2026, 6, 1, 0, 0);
LocalDateTime to = LocalDateTime.of(2026, 6, 3, 0, 0);
when(meteringDataRepository.aggregateConsumptionByDay(eq(meteringPointId), eq(from), eq(to)))
.thenReturn(dailyRows(
new Object[]{152, 2026, 10.0},
new Object[]{153, 2026, 5.0}
));
when(meteringDataRepository.aggregateFeedInByDay(any(), eq(from), eq(to)))
.thenReturn(dailyRows(
new Object[]{152, 2026, 8.0},
new Object[]{153, 2026, 12.0}
));
ConsumptionDashboardResponse response = service.getDashboard(userId, meteringPointId, from, to);
assertNotNull(response);
assertEquals(meteringPointId, response.meteringPointId());
assertEquals(communityId, response.energyCommunityId());
assertEquals("Test-EEG", response.communityName());
assertEquals("DAILY", response.granularity());
assertEquals(2, response.data().size());
assertEquals(15.0, response.summary().totalConsumptionKwh(), 0.01);
assertEquals(13.0, response.summary().totalCoveredKwh(), 0.01);
assertEquals(86.67, response.summary().averageCoveragePercent(), 0.1);
}
@Test
void getDashboard_noMembership_showsConsumptionOnly() {
when(meteringPointRepository.findById(meteringPointId)).thenReturn(Optional.of(meteringPoint));
when(membershipRepository.findByMeteringPointId(meteringPointId)).thenReturn(List.of());
LocalDateTime from = LocalDateTime.of(2026, 6, 1, 0, 0);
LocalDateTime to = LocalDateTime.of(2026, 6, 3, 0, 0);
when(meteringDataRepository.aggregateConsumptionByDay(eq(meteringPointId), eq(from), eq(to)))
.thenReturn(dailyRows(new Object[]{152, 2026, 10.0}));
ConsumptionDashboardResponse response = service.getDashboard(userId, meteringPointId, from, to);
assertNull(response.energyCommunityId());
assertNull(response.communityName());
assertEquals(10.0, response.data().get(0).consumptionKwh());
assertEquals(0.0, response.data().get(0).communityFeedInKwh());
assertEquals(0.0, response.data().get(0).coveragePercent());
}
@Test
void getDashboard_emptyRange_returnsZeroSummary() {
when(meteringPointRepository.findById(meteringPointId)).thenReturn(Optional.of(meteringPoint));
when(membershipRepository.findByMeteringPointId(meteringPointId)).thenReturn(List.of());
LocalDateTime from = LocalDateTime.of(2026, 6, 1, 0, 0);
LocalDateTime to = LocalDateTime.of(2026, 6, 3, 0, 0);
when(meteringDataRepository.aggregateConsumptionByDay(eq(meteringPointId), eq(from), eq(to)))
.thenReturn(dailyRows());
ConsumptionDashboardResponse response = service.getDashboard(userId, meteringPointId, from, to);
assertEquals(0.0, response.summary().totalConsumptionKwh());
assertEquals(0.0, response.summary().averageCoveragePercent());
assertTrue(response.data().isEmpty());
}
@Test
void getDashboard_wrongUser_throwsAccessDenied() {
UUID otherUserId = UUID.randomUUID();
when(meteringPointRepository.findById(meteringPointId)).thenReturn(Optional.of(meteringPoint));
LocalDateTime from = LocalDateTime.of(2026, 6, 1, 0, 0);
LocalDateTime to = LocalDateTime.of(2026, 6, 3, 0, 0);
assertThrows(AccessDeniedException.class,
() -> service.getDashboard(otherUserId, meteringPointId, from, to));
}
@Test
void getDashboard_hourlyGranularity_rangeUnder24h() {
when(meteringPointRepository.findById(meteringPointId)).thenReturn(Optional.of(meteringPoint));
when(membershipRepository.findByMeteringPointId(meteringPointId)).thenReturn(List.of(membership));
when(membershipRepository.findActiveMeteringPointIdsByCommunityId(communityId)).thenReturn(List.of(UUID.randomUUID()));
LocalDateTime from = LocalDateTime.of(2026, 6, 1, 0, 0);
LocalDateTime to = LocalDateTime.of(2026, 6, 1, 12, 0);
when(meteringDataRepository.aggregateConsumptionByHour(eq(meteringPointId), eq(from), eq(to)))
.thenReturn(hourlyRows(
new Object[]{0, 152, 2026, 2.5},
new Object[]{6, 152, 2026, 3.0}
));
when(meteringDataRepository.aggregateFeedInByHour(any(), eq(from), eq(to)))
.thenReturn(hourlyRows(
new Object[]{0, 152, 2026, 1.0},
new Object[]{6, 152, 2026, 5.0}
));
ConsumptionDashboardResponse response = service.getDashboard(userId, meteringPointId, from, to);
assertEquals("HOURLY", response.granularity());
assertEquals(2, response.data().size());
}
@Test
void getDashboard_communityFeedInHigherThanConsumption_coverage100() {
when(meteringPointRepository.findById(meteringPointId)).thenReturn(Optional.of(meteringPoint));
when(membershipRepository.findByMeteringPointId(meteringPointId)).thenReturn(List.of(membership));
when(membershipRepository.findActiveMeteringPointIdsByCommunityId(communityId)).thenReturn(List.of(UUID.randomUUID()));
LocalDateTime from = LocalDateTime.of(2026, 6, 1, 0, 0);
LocalDateTime to = LocalDateTime.of(2026, 6, 3, 0, 0);
when(meteringDataRepository.aggregateConsumptionByDay(eq(meteringPointId), eq(from), eq(to)))
.thenReturn(dailyRows(new Object[]{152, 2026, 5.0}));
when(meteringDataRepository.aggregateFeedInByDay(any(), eq(from), eq(to)))
.thenReturn(dailyRows(new Object[]{152, 2026, 20.0}));
ConsumptionDashboardResponse response = service.getDashboard(userId, meteringPointId, from, to);
assertEquals(5.0, response.data().get(0).coveredKwh());
assertEquals(100.0, response.data().get(0).coveragePercent());
}
}

View File

@ -835,6 +835,37 @@ paths:
type: array
items:
$ref: "#/components/schemas/MembershipResponse"
/api/community/consumption/{meteringPointId}/dashboard:
get:
tags:
- consumption-dashboard-controller
operationId: getDashboard
parameters:
- name: meteringPointId
in: path
required: true
schema:
type: string
format: uuid
- name: from
in: query
required: true
schema:
type: string
format: date-time
- name: to
in: query
required: true
schema:
type: string
format: date-time
responses:
"200":
description: OK
content:
'*/*':
schema:
$ref: "#/components/schemas/ConsumptionDashboardResponse"
/api/community/admin/memberships/pending:
get:
tags:
@ -1507,6 +1538,66 @@ components:
validTo:
type: string
format: date
ConsumptionDashboardResponse:
type: object
properties:
meteringPointId:
type: string
format: uuid
atNumber:
type: string
energyCommunityId:
type: string
format: uuid
communityName:
type: string
from:
type: string
format: date-time
to:
type: string
format: date-time
granularity:
type: string
data:
type: array
items:
$ref: "#/components/schemas/IntervalData"
summary:
$ref: "#/components/schemas/Summary"
IntervalData:
type: object
properties:
timestamp:
type: string
format: date-time
consumptionKwh:
type: number
format: double
communityFeedInKwh:
type: number
format: double
coveredKwh:
type: number
format: double
coveragePercent:
type: number
format: double
Summary:
type: object
properties:
totalConsumptionKwh:
type: number
format: double
totalCoveredKwh:
type: number
format: double
totalCommunityFeedInKwh:
type: number
format: double
averageCoveragePercent:
type: number
format: double
PendingMembershipResponse:
type: object
properties:

View File

@ -17,6 +17,8 @@
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"@tailwindcss/postcss": "^4.3.0",
"echarts": "^6.1.0",
"ngx-echarts": "^18.0.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "^0.16.2"
@ -6776,6 +6778,22 @@
"wcwidth": ">=1.0.1"
}
},
"node_modules/echarts": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.1.0"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@ -9096,6 +9114,18 @@
"node": ">= 0.4.0"
}
},
"node_modules/ngx-echarts": {
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/ngx-echarts/-/ngx-echarts-18.0.0.tgz",
"integrity": "sha512-1rJW7vhMTTQMZNO5AhbHfTDorhP7dcvwRsDH5jFk2SPb/gjIFWvXBY9VSNAOKumuSBnopm2+uSz6BRO5oWxovA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"echarts": ">=5.0.0"
}
},
"node_modules/node-addon-api": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
@ -11569,6 +11599,21 @@
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.16.2.tgz",
"integrity": "sha512-Eky7p2Z1Ig3NnbfodSPoARCjKBSTFMnE/ACsP1L/XJEfY4SdOFce19BsUCWVwL6K5ABZFy5J3bjcMWffX+YM3Q==",
"license": "MIT"
},
"node_modules/zrender": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
}
}
}

View File

@ -21,6 +21,8 @@
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"@tailwindcss/postcss": "^4.3.0",
"echarts": "^6.1.0",
"ngx-echarts": "^18.0.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "^0.16.2"

View File

@ -18,6 +18,7 @@ import {ForgotPasswordComponent} from './pages/forgot-password/forgot-password';
import {ResetPasswordComponent} from './pages/reset-password/reset-password';
import {AdminTariffComponent} from './pages/admin-tariff/admin-tariff';
import {UserTariffComponent} from './pages/user-tariff/user-tariff';
import {ConsumptionDashboardComponent} from './pages/consumption-dashboard/consumption-dashboard';
export const routes: Routes = [
{ path: '', component: LandingPage },
@ -72,6 +73,11 @@ export const routes: Routes = [
component: UserTariffComponent,
canActivate: [roleGuard(['MEMBER'])]
},
{
path: 'consumption',
component: ConsumptionDashboardComponent,
canActivate: [roleGuard(['MEMBER'])]
},
{
path: 'profile',
component: ProfileComponent,

View File

@ -9,6 +9,7 @@ export const NAV_ITEMS: NavItem[] = [
{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: 'Verbrauch', path: '/dashboard/consumption', 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']},

View File

@ -0,0 +1,99 @@
<div class="space-y-6">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-slate-800">Verbrauchs-Dashboard</h1>
<p class="text-slate-500 mt-1">Überblick über deinen Stromverbrauch und die Abdeckung durch die Energiegemeinschaft</p>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-4 items-end">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Zählpunkt</label>
<select
class="border border-slate-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
[ngModel]="selectedMeteringPointId()"
(ngModelChange)="onMeteringPointChange($event)">
<option value="" disabled>Wähle Zählpunkt</option>
@for (mp of meteringPoints(); track mp.id) {
<option [value]="mp.id">{{ mp.atNumber }} ({{ mp.type }})</option>
}
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Von</label>
<input type="date"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
[ngModel]="dateFrom()"
(ngModelChange)="dateFrom.set($event); onDateRangeChange()">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Bis</label>
<input type="date"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
[ngModel]="dateTo()"
(ngModelChange)="dateTo.set($event); onDateRangeChange()">
</div>
<div class="flex gap-2">
<button class="px-3 py-2 text-sm rounded-lg border border-slate-300 hover:bg-slate-50"
(click)="setQuickRange(7)">7 Tage</button>
<button class="px-3 py-2 text-sm rounded-lg border border-slate-300 hover:bg-slate-50"
(click)="setQuickRange(30)">30 Tage</button>
<button class="px-3 py-2 text-sm rounded-lg border border-slate-300 hover:bg-slate-50"
(click)="setQuickRange(90)">90 Tage</button>
</div>
</div>
<!-- No membership warning -->
@if (dashboardData(); as data) {
@if (!data.energyCommunityId) {
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4">
<p class="text-amber-700 text-sm">Du bist noch keiner Energiegemeinschaft beigetreten. Die Verbrauchsdaten werden ohne Community-Abdeckung angezeigt.</p>
</div>
}
}
<!-- Summary Cards -->
@if (dashboardData(); as data) {
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-5">
<p class="text-sm text-slate-500">Gesamtverbrauch</p>
<p class="text-2xl font-bold text-slate-800 mt-1">{{ data.summary.totalConsumptionKwh.toFixed(1) }} <span class="text-sm font-normal">kWh</span></p>
</div>
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-5">
<p class="text-sm text-slate-500">Abdeckung</p>
<p class="text-2xl font-bold text-emerald-600 mt-1">{{ data.summary.averageCoveragePercent.toFixed(1) }} <span class="text-sm font-normal">%</span></p>
</div>
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-5">
<p class="text-sm text-slate-500">Community-Feed-In</p>
<p class="text-2xl font-bold text-orange-500 mt-1">{{ data.summary.totalCommunityFeedInKwh.toFixed(1) }} <span class="text-sm font-normal">kWh</span></p>
</div>
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-5">
<p class="text-sm text-slate-500">Abgedeckt</p>
<p class="text-2xl font-bold text-blue-500 mt-1">{{ data.summary.totalCoveredKwh.toFixed(1) }} <span class="text-sm font-normal">kWh</span></p>
</div>
</div>
}
<!-- Loading -->
@if (isLoading()) {
<div class="flex justify-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 Verbrauchsdaten...</span>
</div>
}
<!-- Error -->
@if (error(); as errorMsg) {
<div class="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<p class="text-red-600">{{ errorMsg }}</p>
</div>
}
<!-- Chart -->
@if (!isLoading() && !error() && chartOptions()) {
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6">
<h2 class="text-lg font-semibold text-slate-800 mb-4">Verbrauch vs. Community-Verfügbarkeit</h2>
<div echarts [options]="chartOptions()" class="h-[400px]"></div>
</div>
}
</div>

View File

@ -0,0 +1,158 @@
import { Component, inject, OnInit, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NgxEchartsDirective } from 'ngx-echarts';
import { ConsumptionService, ConsumptionDashboardResponse } from '../../services/consumption';
import { MeteringPoint, MeteringPointService } from '../../services/metering-point';
@Component({
selector: 'app-consumption-dashboard',
standalone: true,
imports: [FormsModule, NgxEchartsDirective],
templateUrl: './consumption-dashboard.html'
})
export class ConsumptionDashboardComponent implements OnInit {
private consumptionService = inject(ConsumptionService);
private meteringPointService = inject(MeteringPointService);
meteringPoints = signal<MeteringPoint[]>([]);
selectedMeteringPointId = signal<string>('');
dashboardData = signal<ConsumptionDashboardResponse | null>(null);
isLoading = signal(false);
error = signal<string | null>(null);
dateFrom = signal<string>(this.getDefaultFromDate());
dateTo = signal<string>(this.getDefaultToDate());
chartOptions = signal<any>({});
ngOnInit() {
this.meteringPointService.getMyMeteringPoints().subscribe({
next: (points) => {
this.meteringPoints.set(points);
if (points.length === 1) {
this.selectedMeteringPointId.set(points[0].id);
this.loadDashboard();
}
},
error: () => this.error.set('Fehler beim Laden der Zählpunkte.')
});
}
onMeteringPointChange(mpId: string) {
this.selectedMeteringPointId.set(mpId);
this.loadDashboard();
}
onDateRangeChange() {
this.loadDashboard();
}
setQuickRange(days: number) {
const to = new Date();
const from = new Date();
from.setDate(from.getDate() - days);
this.dateFrom.set(this.formatDate(from));
this.dateTo.set(this.formatDate(to));
this.loadDashboard();
}
private loadDashboard() {
const mpId = this.selectedMeteringPointId();
if (!mpId) return;
this.isLoading.set(true);
this.error.set(null);
const from = this.dateFrom() + 'T00:00:00';
const to = this.dateTo() + 'T23:59:59';
this.consumptionService.getDashboard(mpId, from, to).subscribe({
next: (data) => {
this.dashboardData.set(data);
this.buildChartOptions(data);
this.isLoading.set(false);
},
error: () => {
this.isLoading.set(false);
this.error.set('Fehler beim Laden der Verbrauchsdaten.');
}
});
}
private buildChartOptions(data: ConsumptionDashboardResponse) {
const labels = data.data.map(d => {
const date = new Date(d.timestamp);
return data.granularity === 'DAILY'
? date.toLocaleDateString('de-AT', { day: '2-digit', month: '2-digit' })
: date.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' });
});
this.chartOptions.set({
tooltip: {
trigger: 'axis',
formatter: (params: any[]) => {
const label = params[0]?.axisValue || '';
let html = `<strong>${label}</strong><br/>`;
params.forEach((p: any) => {
html += `${p.marker} ${p.seriesName}: ${p.value?.toFixed(2)} kWh<br/>`;
});
const idx = params[0]?.dataIndex;
if (idx !== undefined && data.data[idx]) {
html += `<br/>Abdeckung: ${data.data[idx].coveragePercent.toFixed(1)}%`;
}
return html;
}
},
legend: {
data: ['Verbrauch', 'Community-Verfügbarkeit', 'Abgedeckt'],
top: 0
},
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: {
type: 'category',
data: labels,
axisLabel: { rotate: labels.length > 15 ? 45 : 0 }
},
yAxis: { type: 'value', name: 'kWh' },
series: [
{
name: 'Verbrauch',
type: 'bar',
data: data.data.map(d => d.consumptionKwh),
itemStyle: { color: '#3b82f6' },
barMaxWidth: 40
},
{
name: 'Abgedeckt',
type: 'bar',
data: data.data.map(d => d.coveredKwh),
itemStyle: { color: '#22c55e' },
barMaxWidth: 40
},
{
name: 'Community-Verfügbarkeit',
type: 'line',
data: data.data.map(d => d.communityFeedInKwh),
itemStyle: { color: '#f97316' },
lineStyle: { width: 2 },
symbol: 'circle',
symbolSize: 4
}
]
});
}
private getDefaultFromDate(): string {
const d = new Date();
d.setDate(d.getDate() - 30);
return this.formatDate(d);
}
private getDefaultToDate(): string {
return this.formatDate(new Date());
}
private formatDate(d: Date): string {
return d.toISOString().split('T')[0];
}
}

View File

@ -0,0 +1,52 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export interface IntervalData {
timestamp: string;
consumptionKwh: number;
communityFeedInKwh: number;
coveredKwh: number;
coveragePercent: number;
}
export interface Summary {
totalConsumptionKwh: number;
totalCoveredKwh: number;
totalCommunityFeedInKwh: number;
averageCoveragePercent: number;
}
export interface ConsumptionDashboardResponse {
meteringPointId: string;
atNumber: string;
energyCommunityId: string | null;
communityName: string | null;
from: string;
to: string;
granularity: 'DAILY' | 'HOURLY';
data: IntervalData[];
summary: Summary;
}
@Injectable({ providedIn: 'root' })
export class ConsumptionService {
private http = inject(HttpClient);
private apiUrl = `${environment.apiUrl}/api/community/consumption`;
getDashboard(
meteringPointId: string,
from: string,
to: string
): Observable<ConsumptionDashboardResponse> {
const params = new HttpParams()
.set('from', from)
.set('to', to);
return this.http.get<ConsumptionDashboardResponse>(
`${this.apiUrl}/${meteringPointId}/dashboard`,
{ params }
);
}
}