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:
parent
975f76dbd3
commit
4b7b26231f
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@ -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'")
|
@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);
|
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);
|
||||||
}
|
}
|
||||||
@ -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.MeteringData;
|
||||||
import at.mueller.eeg.backend.community.domain.MeteringDataType;
|
import at.mueller.eeg.backend.community.domain.MeteringDataType;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -20,4 +22,40 @@ public interface MeteringDataRepository extends JpaRepository<MeteringData, UUID
|
|||||||
|
|
||||||
List<MeteringData> findByMeteringPointIdAndDataTypeAndIntervalStart(
|
List<MeteringData> findByMeteringPointIdAndDataTypeAndIntervalStart(
|
||||||
UUID meteringPointId, MeteringDataType dataType, LocalDateTime intervalStart);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -835,6 +835,37 @@ paths:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/MembershipResponse"
|
$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:
|
/api/community/admin/memberships/pending:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@ -1507,6 +1538,66 @@ components:
|
|||||||
validTo:
|
validTo:
|
||||||
type: string
|
type: string
|
||||||
format: date
|
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:
|
PendingMembershipResponse:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
45
eeg_frontend/package-lock.json
generated
45
eeg_frontend/package-lock.json
generated
@ -17,6 +17,8 @@
|
|||||||
"@angular/platform-browser": "^21.2.0",
|
"@angular/platform-browser": "^21.2.0",
|
||||||
"@angular/router": "^21.2.0",
|
"@angular/router": "^21.2.0",
|
||||||
"@tailwindcss/postcss": "^4.3.0",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
|
"echarts": "^6.1.0",
|
||||||
|
"ngx-echarts": "^18.0.0",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"zone.js": "^0.16.2"
|
"zone.js": "^0.16.2"
|
||||||
@ -6776,6 +6778,22 @@
|
|||||||
"wcwidth": ">=1.0.1"
|
"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": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
@ -9096,6 +9114,18 @@
|
|||||||
"node": ">= 0.4.0"
|
"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": {
|
"node_modules/node-addon-api": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.16.2.tgz",
|
||||||
"integrity": "sha512-Eky7p2Z1Ig3NnbfodSPoARCjKBSTFMnE/ACsP1L/XJEfY4SdOFce19BsUCWVwL6K5ABZFy5J3bjcMWffX+YM3Q==",
|
"integrity": "sha512-Eky7p2Z1Ig3NnbfodSPoARCjKBSTFMnE/ACsP1L/XJEfY4SdOFce19BsUCWVwL6K5ABZFy5J3bjcMWffX+YM3Q==",
|
||||||
"license": "MIT"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,8 @@
|
|||||||
"@angular/platform-browser": "^21.2.0",
|
"@angular/platform-browser": "^21.2.0",
|
||||||
"@angular/router": "^21.2.0",
|
"@angular/router": "^21.2.0",
|
||||||
"@tailwindcss/postcss": "^4.3.0",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
|
"echarts": "^6.1.0",
|
||||||
|
"ngx-echarts": "^18.0.0",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"zone.js": "^0.16.2"
|
"zone.js": "^0.16.2"
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {ForgotPasswordComponent} from './pages/forgot-password/forgot-password';
|
|||||||
import {ResetPasswordComponent} from './pages/reset-password/reset-password';
|
import {ResetPasswordComponent} from './pages/reset-password/reset-password';
|
||||||
import {AdminTariffComponent} from './pages/admin-tariff/admin-tariff';
|
import {AdminTariffComponent} from './pages/admin-tariff/admin-tariff';
|
||||||
import {UserTariffComponent} from './pages/user-tariff/user-tariff';
|
import {UserTariffComponent} from './pages/user-tariff/user-tariff';
|
||||||
|
import {ConsumptionDashboardComponent} from './pages/consumption-dashboard/consumption-dashboard';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', component: LandingPage },
|
{ path: '', component: LandingPage },
|
||||||
@ -72,6 +73,11 @@ export const routes: Routes = [
|
|||||||
component: UserTariffComponent,
|
component: UserTariffComponent,
|
||||||
canActivate: [roleGuard(['MEMBER'])]
|
canActivate: [roleGuard(['MEMBER'])]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'consumption',
|
||||||
|
component: ConsumptionDashboardComponent,
|
||||||
|
canActivate: [roleGuard(['MEMBER'])]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'profile',
|
path: 'profile',
|
||||||
component: ProfileComponent,
|
component: ProfileComponent,
|
||||||
|
|||||||
@ -9,6 +9,7 @@ export const NAV_ITEMS: NavItem[] = [
|
|||||||
{label: 'Zählpunkte', path: '/dashboard/metering-points', roles: ['ADMIN', 'MEMBER']},
|
{label: 'Zählpunkte', path: '/dashboard/metering-points', roles: ['ADMIN', 'MEMBER']},
|
||||||
{label: 'Community beitreten', path: '/dashboard/join-community', roles: ['MEMBER']},
|
{label: 'Community beitreten', path: '/dashboard/join-community', roles: ['MEMBER']},
|
||||||
{label: 'Meine Mitgliedschaften', path: '/dashboard/my-memberships', 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: 'Mitgliedschaften', path: '/dashboard/admin-memberships', roles: ['ADMIN']},
|
||||||
{label: 'Mein Profil', path: '/dashboard/profile', roles: ['ADMIN', 'MEMBER']},
|
{label: 'Mein Profil', path: '/dashboard/profile', roles: ['ADMIN', 'MEMBER']},
|
||||||
{label: 'Benutzerverwaltung', path: '/dashboard/approvals', roles: ['ADMIN']},
|
{label: 'Benutzerverwaltung', path: '/dashboard/approvals', roles: ['ADMIN']},
|
||||||
|
|||||||
@ -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>
|
||||||
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
52
eeg_frontend/src/app/services/consumption.ts
Normal file
52
eeg_frontend/src/app/services/consumption.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user