feat(community): add metering data upload UI with preview and validation
- Add frontend upload page with file selection, drag-and-drop, and preview - Add preview endpoint (POST /api/community/metering-data/preview/xlsx) - Validate FEED_IN only for PRODUCER, CONSUMPTION only for CONSUMER metering points - Optimize deduplication with bulk query (fix N+1 problem) - Eliminate duplicate DB query in validateRecords - Add 4 new tests for data type validation - Add navigation entry for admin users
This commit is contained in:
parent
4b7b26231f
commit
a651ec946c
@ -61,6 +61,14 @@ public class MeteringDataController {
|
|||||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/preview/xlsx", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
public ResponseEntity<List<MeteringDataRecordDto>> previewXlsx(
|
||||||
|
@RequestParam("file") MultipartFile file) throws IOException {
|
||||||
|
List<MeteringDataRecordDto> records = xlsxParser.parse(file.getInputStream());
|
||||||
|
return ResponseEntity.ok(records.stream().limit(10).toList());
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{meteringPointId}")
|
@GetMapping("/{meteringPointId}")
|
||||||
@PreAuthorize("hasRole('MEMBER')")
|
@PreAuthorize("hasRole('MEMBER')")
|
||||||
public ResponseEntity<List<MeteringDataResponse>> getMeteringData(
|
public ResponseEntity<List<MeteringDataResponse>> getMeteringData(
|
||||||
|
|||||||
@ -23,6 +23,13 @@ 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 m FROM MeteringData m WHERE m.meteringPoint.id = :meteringPointId " +
|
||||||
|
"AND m.dataType = :dataType AND m.intervalStart IN :intervalStarts")
|
||||||
|
List<MeteringData> findByMeteringPointIdAndDataTypeAndIntervalStartIn(
|
||||||
|
@Param("meteringPointId") UUID meteringPointId,
|
||||||
|
@Param("dataType") MeteringDataType dataType,
|
||||||
|
@Param("intervalStarts") List<LocalDateTime> intervalStarts);
|
||||||
|
|
||||||
@Query("SELECT FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('YEAR', m.intervalStart), SUM(m.kwh) " +
|
@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' " +
|
"FROM MeteringData m WHERE m.meteringPoint.id = :meteringPointId AND m.dataType = 'CONSUMPTION' " +
|
||||||
"AND m.intervalStart BETWEEN :from AND :to " +
|
"AND m.intervalStart BETWEEN :from AND :to " +
|
||||||
|
|||||||
@ -29,7 +29,8 @@ public class MeteringDataService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public MeteringDataUploadResponse uploadMeteringData(MeteringDataUploadRequest request, UUID uploadedBy) {
|
public MeteringDataUploadResponse uploadMeteringData(MeteringDataUploadRequest request, UUID uploadedBy) {
|
||||||
List<String> validationErrors = validateRecords(request.records());
|
Map<String, MeteringPoint> pointsByAtNumber = resolveMeteringPoints(request.records());
|
||||||
|
List<String> validationErrors = validateRecords(request.records(), pointsByAtNumber);
|
||||||
|
|
||||||
if (!validationErrors.isEmpty()) {
|
if (!validationErrors.isEmpty()) {
|
||||||
return MeteringDataUploadResponse.validationFailed(validationErrors);
|
return MeteringDataUploadResponse.validationFailed(validationErrors);
|
||||||
@ -43,8 +44,6 @@ public class MeteringDataService {
|
|||||||
upload.setUploadedBy(uploadedBy);
|
upload.setUploadedBy(uploadedBy);
|
||||||
MeteringDataUpload savedUpload = uploadRepository.save(upload);
|
MeteringDataUpload savedUpload = uploadRepository.save(upload);
|
||||||
|
|
||||||
Map<String, MeteringPoint> pointsByAtNumber = resolveMeteringPoints(request.records());
|
|
||||||
|
|
||||||
List<MeteringData> entities = request.records().stream()
|
List<MeteringData> entities = request.records().stream()
|
||||||
.map(record -> toEntity(record, pointsByAtNumber.get(record.atNumber()),
|
.map(record -> toEntity(record, pointsByAtNumber.get(record.atNumber()),
|
||||||
savedUpload.getId(), request.source()))
|
savedUpload.getId(), request.source()))
|
||||||
@ -92,7 +91,7 @@ public class MeteringDataService {
|
|||||||
"Keine Berechtigung zum Zugriff auf diesen Zählpunkt"));
|
"Keine Berechtigung zum Zugriff auf diesen Zählpunkt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<String> validateRecords(List<MeteringDataRecordDto> records) {
|
private List<String> validateRecords(List<MeteringDataRecordDto> records, Map<String, MeteringPoint> pointsByAtNumber) {
|
||||||
List<String> errors = new ArrayList<>();
|
List<String> errors = new ArrayList<>();
|
||||||
|
|
||||||
if (records == null || records.isEmpty()) {
|
if (records == null || records.isEmpty()) {
|
||||||
@ -100,22 +99,17 @@ public class MeteringDataService {
|
|||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
Set<String> atNumbers = records.stream()
|
|
||||||
.map(MeteringDataRecordDto::atNumber)
|
|
||||||
.collect(Collectors.toSet());
|
|
||||||
|
|
||||||
Set<String> existingAtNumbers = meteringPointRepository.findByAtNumberIn(atNumbers).stream()
|
|
||||||
.map(MeteringPoint::getAtNumber)
|
|
||||||
.collect(Collectors.toSet());
|
|
||||||
|
|
||||||
for (int i = 0; i < records.size(); i++) {
|
for (int i = 0; i < records.size(); i++) {
|
||||||
MeteringDataRecordDto record = records.get(i);
|
MeteringDataRecordDto record = records.get(i);
|
||||||
String prefix = "Zeile " + (i + 1) + ": ";
|
String prefix = "Zeile " + (i + 1) + ": ";
|
||||||
|
|
||||||
if (!existingAtNumbers.contains(record.atNumber())) {
|
if (!pointsByAtNumber.containsKey(record.atNumber())) {
|
||||||
errors.add(prefix + "Zählpunkt " + record.atNumber() + " nicht im System vorhanden");
|
errors.add(prefix + "Zählpunkt " + record.atNumber() + " nicht im System vorhanden");
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MeteringPoint point = pointsByAtNumber.get(record.atNumber());
|
||||||
|
|
||||||
if (record.intervalEnd().isBefore(record.intervalStart())) {
|
if (record.intervalEnd().isBefore(record.intervalStart())) {
|
||||||
errors.add(prefix + "Intervall-Ende liegt vor Intervall-Beginn");
|
errors.add(prefix + "Intervall-Ende liegt vor Intervall-Beginn");
|
||||||
}
|
}
|
||||||
@ -123,6 +117,13 @@ public class MeteringDataService {
|
|||||||
if (record.kwh() < 0) {
|
if (record.kwh() < 0) {
|
||||||
errors.add(prefix + "kWh-Wert darf nicht negativ sein");
|
errors.add(prefix + "kWh-Wert darf nicht negativ sein");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (record.dataType() == MeteringDataType.FEED_IN && point.getType() != PointType.PRODUCER) {
|
||||||
|
errors.add(prefix + "Einspeisedaten (FEED_IN) nur für PRODUCER-Zählpunkte erlaubt");
|
||||||
|
}
|
||||||
|
if (record.dataType() == MeteringDataType.CONSUMPTION && point.getType() != PointType.CONSUMER) {
|
||||||
|
errors.add(prefix + "Verbrauchsdaten (CONSUMPTION) nur für CONSUMER-Zählpunkte erlaubt");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
@ -156,15 +157,25 @@ public class MeteringDataService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<UUID, Map<MeteringDataType, List<LocalDateTime>>> grouped = entities.stream()
|
||||||
|
.collect(Collectors.groupingBy(
|
||||||
|
e -> e.getMeteringPoint().getId(),
|
||||||
|
Collectors.groupingBy(
|
||||||
|
MeteringData::getDataType,
|
||||||
|
Collectors.mapping(MeteringData::getIntervalStart, Collectors.toList())
|
||||||
|
)
|
||||||
|
));
|
||||||
|
|
||||||
Set<MeteringData> toDelete = new LinkedHashSet<>();
|
Set<MeteringData> toDelete = new LinkedHashSet<>();
|
||||||
|
|
||||||
for (MeteringData entity : entities) {
|
for (var pointEntry : grouped.entrySet()) {
|
||||||
meteringDataRepository
|
UUID pointId = pointEntry.getKey();
|
||||||
.findByMeteringPointIdAndDataTypeAndIntervalStart(
|
for (var typeEntry : pointEntry.getValue().entrySet()) {
|
||||||
entity.getMeteringPoint().getId(),
|
MeteringDataType dataType = typeEntry.getKey();
|
||||||
entity.getDataType(),
|
List<LocalDateTime> starts = typeEntry.getValue();
|
||||||
entity.getIntervalStart())
|
toDelete.addAll(meteringDataRepository.findByMeteringPointIdAndDataTypeAndIntervalStartIn(
|
||||||
.forEach(toDelete::add);
|
pointId, dataType, starts));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!toDelete.isEmpty()) {
|
if (!toDelete.isEmpty()) {
|
||||||
|
|||||||
@ -190,7 +190,15 @@ class MeteringDataServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void uploadMeteringData_multipleRecords() {
|
void uploadMeteringData_multipleRecords() {
|
||||||
when(meteringPointRepository.findByAtNumberIn(any())).thenReturn(List.of(testMeteringPoint));
|
String producerAtNumber = "AT0010000000000000000000001234568";
|
||||||
|
MeteringPoint producerPoint = new MeteringPoint();
|
||||||
|
producerPoint.setId(UUID.randomUUID());
|
||||||
|
producerPoint.setUserId(testUserId);
|
||||||
|
producerPoint.setAtNumber(producerAtNumber);
|
||||||
|
producerPoint.setType(PointType.PRODUCER);
|
||||||
|
producerPoint.setMakoState(MakoState.ACTIVE);
|
||||||
|
|
||||||
|
when(meteringPointRepository.findByAtNumberIn(any())).thenReturn(List.of(testMeteringPoint, producerPoint));
|
||||||
when(uploadRepository.save(any())).thenAnswer(invocation -> {
|
when(uploadRepository.save(any())).thenAnswer(invocation -> {
|
||||||
MeteringDataUpload upload = invocation.getArgument(0);
|
MeteringDataUpload upload = invocation.getArgument(0);
|
||||||
upload.setId(UUID.randomUUID());
|
upload.setId(UUID.randomUUID());
|
||||||
@ -203,7 +211,7 @@ class MeteringDataServiceTest {
|
|||||||
LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 1, 0, 15), 1.25, null),
|
LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 1, 0, 15), 1.25, null),
|
||||||
new MeteringDataRecordDto(testAtNumber, MeteringDataType.CONSUMPTION,
|
new MeteringDataRecordDto(testAtNumber, MeteringDataType.CONSUMPTION,
|
||||||
LocalDateTime.of(2026, 1, 1, 0, 15), LocalDateTime.of(2026, 1, 1, 0, 30), 0.80, null),
|
LocalDateTime.of(2026, 1, 1, 0, 15), LocalDateTime.of(2026, 1, 1, 0, 30), 0.80, null),
|
||||||
new MeteringDataRecordDto(testAtNumber, MeteringDataType.FEED_IN,
|
new MeteringDataRecordDto(producerAtNumber, MeteringDataType.FEED_IN,
|
||||||
LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 1, 0, 15), 2.50, null)
|
LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 1, 0, 15), 2.50, null)
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -291,4 +299,95 @@ class MeteringDataServiceTest {
|
|||||||
MeteringDataType.CONSUMPTION,
|
MeteringDataType.CONSUMPTION,
|
||||||
LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 31, 23, 59)));
|
LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 31, 23, 59)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uploadMeteringData_validationFailsForFeedInOnConsumerPoint() {
|
||||||
|
when(meteringPointRepository.findByAtNumberIn(any())).thenReturn(List.of(testMeteringPoint));
|
||||||
|
|
||||||
|
MeteringDataRecordDto record = new MeteringDataRecordDto(
|
||||||
|
testAtNumber,
|
||||||
|
MeteringDataType.FEED_IN,
|
||||||
|
LocalDateTime.of(2026, 1, 1, 0, 0),
|
||||||
|
LocalDateTime.of(2026, 1, 1, 0, 15),
|
||||||
|
2.50,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
MeteringDataUploadRequest request = new MeteringDataUploadRequest(
|
||||||
|
DataSource.EMAIL_XLSX,
|
||||||
|
"test.xlsx",
|
||||||
|
List.of(record)
|
||||||
|
);
|
||||||
|
|
||||||
|
MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, UUID.randomUUID());
|
||||||
|
|
||||||
|
assertEquals(UploadStatus.FAILED, response.status());
|
||||||
|
assertFalse(response.validationErrors().isEmpty());
|
||||||
|
assertTrue(response.validationErrors().get(0).contains("PRODUCER"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uploadMeteringData_validationFailsForConsumptionOnProducerPoint() {
|
||||||
|
String producerAtNumber = "AT0010000000000000000000001234568";
|
||||||
|
MeteringPoint producerPoint = new MeteringPoint();
|
||||||
|
producerPoint.setId(UUID.randomUUID());
|
||||||
|
producerPoint.setUserId(testUserId);
|
||||||
|
producerPoint.setAtNumber(producerAtNumber);
|
||||||
|
producerPoint.setType(PointType.PRODUCER);
|
||||||
|
producerPoint.setMakoState(MakoState.ACTIVE);
|
||||||
|
|
||||||
|
when(meteringPointRepository.findByAtNumberIn(any())).thenReturn(List.of(producerPoint));
|
||||||
|
|
||||||
|
MeteringDataRecordDto record = new MeteringDataRecordDto(
|
||||||
|
producerAtNumber,
|
||||||
|
MeteringDataType.CONSUMPTION,
|
||||||
|
LocalDateTime.of(2026, 1, 1, 0, 0),
|
||||||
|
LocalDateTime.of(2026, 1, 1, 0, 15),
|
||||||
|
1.25,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
MeteringDataUploadRequest request = new MeteringDataUploadRequest(
|
||||||
|
DataSource.EMAIL_XLSX,
|
||||||
|
"test.xlsx",
|
||||||
|
List.of(record)
|
||||||
|
);
|
||||||
|
|
||||||
|
MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, UUID.randomUUID());
|
||||||
|
|
||||||
|
assertEquals(UploadStatus.FAILED, response.status());
|
||||||
|
assertFalse(response.validationErrors().isEmpty());
|
||||||
|
assertTrue(response.validationErrors().get(0).contains("CONSUMER"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uploadMeteringData_allowsSelfConsumptionOnBothTypes() {
|
||||||
|
when(meteringPointRepository.findByAtNumberIn(any())).thenReturn(List.of(testMeteringPoint));
|
||||||
|
when(uploadRepository.save(any())).thenAnswer(invocation -> {
|
||||||
|
MeteringDataUpload upload = invocation.getArgument(0);
|
||||||
|
upload.setId(UUID.randomUUID());
|
||||||
|
return upload;
|
||||||
|
});
|
||||||
|
when(meteringDataRepository.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
|
MeteringDataRecordDto record = new MeteringDataRecordDto(
|
||||||
|
testAtNumber,
|
||||||
|
MeteringDataType.SELF_CONSUMPTION,
|
||||||
|
LocalDateTime.of(2026, 1, 1, 0, 0),
|
||||||
|
LocalDateTime.of(2026, 1, 1, 0, 15),
|
||||||
|
0.50,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
MeteringDataUploadRequest request = new MeteringDataUploadRequest(
|
||||||
|
DataSource.EMAIL_XLSX,
|
||||||
|
"test.xlsx",
|
||||||
|
List.of(record)
|
||||||
|
);
|
||||||
|
|
||||||
|
MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, UUID.randomUUID());
|
||||||
|
|
||||||
|
assertEquals(UploadStatus.COMPLETED, response.status());
|
||||||
|
assertEquals(1, response.recordCount());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ 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';
|
import {ConsumptionDashboardComponent} from './pages/consumption-dashboard/consumption-dashboard';
|
||||||
|
import {UploadMeteringDataComponent} from './pages/upload-metering-data/upload-metering-data';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', component: LandingPage },
|
{ path: '', component: LandingPage },
|
||||||
@ -68,6 +69,11 @@ export const routes: Routes = [
|
|||||||
component: AdminTariffComponent,
|
component: AdminTariffComponent,
|
||||||
canActivate: [roleGuard(['ADMIN'])]
|
canActivate: [roleGuard(['ADMIN'])]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'upload-metering-data',
|
||||||
|
component: UploadMeteringDataComponent,
|
||||||
|
canActivate: [roleGuard(['ADMIN'])]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'my-tariffs',
|
path: 'my-tariffs',
|
||||||
component: UserTariffComponent,
|
component: UserTariffComponent,
|
||||||
|
|||||||
@ -15,5 +15,6 @@ export const NAV_ITEMS: NavItem[] = [
|
|||||||
{label: 'Benutzerverwaltung', path: '/dashboard/approvals', roles: ['ADMIN']},
|
{label: 'Benutzerverwaltung', path: '/dashboard/approvals', roles: ['ADMIN']},
|
||||||
{label: 'Energiegemeinschaften', path: '/dashboard/energy-communities', roles: ['ADMIN']},
|
{label: 'Energiegemeinschaften', path: '/dashboard/energy-communities', roles: ['ADMIN']},
|
||||||
{label: 'Tarife verwalten', path: '/dashboard/tariffs', roles: ['ADMIN']},
|
{label: 'Tarife verwalten', path: '/dashboard/tariffs', roles: ['ADMIN']},
|
||||||
|
{label: 'Messdaten-Upload', path: '/dashboard/upload-metering-data', roles: ['ADMIN']},
|
||||||
{label: 'Meine Tarife', path: '/dashboard/my-tariffs', roles: ['MEMBER']}
|
{label: 'Meine Tarife', path: '/dashboard/my-tariffs', roles: ['MEMBER']}
|
||||||
];
|
];
|
||||||
|
|||||||
@ -0,0 +1,113 @@
|
|||||||
|
<div class="max-w-2xl mx-auto p-6">
|
||||||
|
<h1 class="text-2xl font-bold mb-6">Messdaten-Upload</h1>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
|
<p class="text-gray-600 mb-4">
|
||||||
|
Laden Sie eine Excel-Datei (XLSX) mit Messdaten hoch.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-blue-400 transition-colors"
|
||||||
|
(dragover)="onDragOver($event)"
|
||||||
|
(drop)="onDrop($event)"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.xls"
|
||||||
|
(change)="onFileSelected($event)"
|
||||||
|
class="hidden"
|
||||||
|
#fileInput
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
(click)="fileInput.click()"
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Datei auswählen
|
||||||
|
</button>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">
|
||||||
|
oder Datei hierher ziehen
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (selectedFile()) {
|
||||||
|
<div class="mt-4 p-3 bg-gray-50 rounded flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-700">{{ selectedFile()!.name }}</span>
|
||||||
|
<button
|
||||||
|
(click)="upload()"
|
||||||
|
[disabled]="isUploading()"
|
||||||
|
class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
@if (isUploading()) {
|
||||||
|
Wird hochgeladen...
|
||||||
|
} @else {
|
||||||
|
Upload starten
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isLoadingPreview()) {
|
||||||
|
<div class="mt-4 text-center text-gray-500">
|
||||||
|
Vorschau wird geladen...
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (previewData().length > 0) {
|
||||||
|
<div class="mt-4">
|
||||||
|
<h3 class="text-sm font-medium text-gray-700 mb-2">Vorschau (erste 10 Zeilen):</h3>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead class="bg-gray-100">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 text-left">AT-Nummer</th>
|
||||||
|
<th class="px-3 py-2 text-left">Typ</th>
|
||||||
|
<th class="px-3 py-2 text-left">Von</th>
|
||||||
|
<th class="px-3 py-2 text-left">Bis</th>
|
||||||
|
<th class="px-3 py-2 text-right">kWh</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of previewData(); track $index) {
|
||||||
|
<tr class="border-t">
|
||||||
|
<td class="px-3 py-2">{{ row.atNumber }}</td>
|
||||||
|
<td class="px-3 py-2">{{ row.dataType }}</td>
|
||||||
|
<td class="px-3 py-2">{{ row.intervalStart }}</td>
|
||||||
|
<td class="px-3 py-2">{{ row.intervalEnd }}</td>
|
||||||
|
<td class="px-3 py-2 text-right">{{ row.kwh }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (uploadResult()) {
|
||||||
|
<div class="mt-4 p-4 rounded" [class]="uploadResult()!.status === 'COMPLETED' ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'">
|
||||||
|
<p class="font-semibold" [class]="uploadResult()!.status === 'COMPLETED' ? 'text-green-800' : 'text-red-800'">
|
||||||
|
@if (uploadResult()!.status === 'COMPLETED') {
|
||||||
|
Upload erfolgreich!
|
||||||
|
} @else {
|
||||||
|
Upload fehlgeschlagen
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-600 mt-1">
|
||||||
|
{{ uploadResult()!.recordCount }} Datensätze verarbeitet
|
||||||
|
</p>
|
||||||
|
@if (uploadResult()!.validationErrors.length > 0) {
|
||||||
|
<ul class="mt-2 text-sm text-red-600 list-disc list-inside">
|
||||||
|
@for (error of uploadResult()!.validationErrors; track error) {
|
||||||
|
<li>{{ error }}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (errorMessage()) {
|
||||||
|
<div class="mt-4 p-4 bg-red-50 border border-red-200 rounded">
|
||||||
|
<p class="text-red-800">{{ errorMessage() }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,113 @@
|
|||||||
|
import {Component, inject, signal} from '@angular/core';
|
||||||
|
import {HttpClient} from '@angular/common/http';
|
||||||
|
import {environment} from '../../../environments/environment';
|
||||||
|
|
||||||
|
interface PreviewRecord {
|
||||||
|
atNumber: string;
|
||||||
|
dataType: string;
|
||||||
|
intervalStart: string;
|
||||||
|
intervalEnd: string;
|
||||||
|
kwh: number;
|
||||||
|
readingValue: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadResponse {
|
||||||
|
uploadId: string;
|
||||||
|
status: string;
|
||||||
|
recordCount: number;
|
||||||
|
validationErrors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-upload-metering-data',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './upload-metering-data.html',
|
||||||
|
})
|
||||||
|
export class UploadMeteringDataComponent {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
selectedFile = signal<File | null>(null);
|
||||||
|
previewData = signal<PreviewRecord[]>([]);
|
||||||
|
isLoadingPreview = signal(false);
|
||||||
|
isUploading = signal(false);
|
||||||
|
uploadResult = signal<UploadResponse | null>(null);
|
||||||
|
errorMessage = signal<string | null>(null);
|
||||||
|
|
||||||
|
onFileSelected(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
if (input.files && input.files.length > 0) {
|
||||||
|
this.selectedFile.set(input.files[0]);
|
||||||
|
this.uploadResult.set(null);
|
||||||
|
this.errorMessage.set(null);
|
||||||
|
this.loadPreview(input.files[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDragOver(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDrop(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const files = event.dataTransfer?.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
this.selectedFile.set(files[0]);
|
||||||
|
this.uploadResult.set(null);
|
||||||
|
this.errorMessage.set(null);
|
||||||
|
this.loadPreview(files[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadPreview(file: File) {
|
||||||
|
this.isLoadingPreview.set(true);
|
||||||
|
this.previewData.set([]);
|
||||||
|
this.errorMessage.set(null);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
this.http.post<PreviewRecord[]>(
|
||||||
|
`${environment.apiUrl}/api/community/metering-data/preview/xlsx`,
|
||||||
|
formData
|
||||||
|
).subscribe({
|
||||||
|
next: (data) => {
|
||||||
|
this.previewData.set(data);
|
||||||
|
this.isLoadingPreview.set(false);
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.errorMessage.set(error.error?.message || 'Fehler beim Laden der Vorschau');
|
||||||
|
this.isLoadingPreview.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
upload() {
|
||||||
|
const file = this.selectedFile();
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
this.isUploading.set(true);
|
||||||
|
this.uploadResult.set(null);
|
||||||
|
this.errorMessage.set(null);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
this.http.post<UploadResponse>(
|
||||||
|
`${environment.apiUrl}/api/community/metering-data/upload/xlsx`,
|
||||||
|
formData
|
||||||
|
).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.uploadResult.set(response);
|
||||||
|
this.isUploading.set(false);
|
||||||
|
this.selectedFile.set(null);
|
||||||
|
this.previewData.set([]);
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.errorMessage.set(error.error?.message || 'Fehler beim Upload');
|
||||||
|
this.isUploading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user