ui(admin/system): improve mass-import card (loading state, i18n, font size) #569

Merged
marcel merged 10 commits from feat/issue-533-mass-import-ux into main 2026-05-14 17:20:20 +02:00
3 changed files with 48 additions and 8 deletions
Showing only changes of commit 31eacb6d06 - Show all commits

View File

@@ -52,9 +52,9 @@ public class MassImportService {
public enum State { IDLE, RUNNING, DONE, FAILED } public enum State { IDLE, RUNNING, DONE, FAILED }
public record ImportStatus(State state, String message, int processed, LocalDateTime startedAt) {} public record ImportStatus(State state, String statusCode, String message, int processed, LocalDateTime startedAt) {}
private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "Kein Import gestartet.", 0, null); private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
public ImportStatus getStatus() { public ImportStatus getStatus() {
return currentStatus; return currentStatus;
@@ -116,20 +116,29 @@ public class MassImportService {
if (currentStatus.state() == State.RUNNING) { if (currentStatus.state() == State.RUNNING) {
throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress"); throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress");
} }
currentStatus = new ImportStatus(State.RUNNING, "Import läuft...", 0, LocalDateTime.now()); currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, LocalDateTime.now());
try { try {
File spreadsheet = findSpreadsheetFile(); File spreadsheet = findSpreadsheetFile();
log.info("Starte Massenimport aus: {}", spreadsheet.getAbsolutePath()); log.info("Starte Massenimport aus: {}", spreadsheet.getAbsolutePath());
int processed = processRows(readSpreadsheet(spreadsheet)); int processed = processRows(readSpreadsheet(spreadsheet));
currentStatus = new ImportStatus(State.DONE, currentStatus = new ImportStatus(State.DONE, "IMPORT_DONE",
"Import abgeschlossen. " + processed + " Dokumente verarbeitet.", "Import abgeschlossen. " + processed + " Dokumente verarbeitet.",
processed, currentStatus.startedAt()); processed, currentStatus.startedAt());
} catch (NoSpreadsheetException e) {
log.error("Massenimport fehlgeschlagen: keine Tabellendatei", e);
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_NO_SPREADSHEET",
"Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
} catch (Exception e) { } catch (Exception e) {
log.error("Massenimport fehlgeschlagen", e); log.error("Massenimport fehlgeschlagen", e);
currentStatus = new ImportStatus(State.FAILED, "Fehler: " + e.getMessage(), 0, currentStatus.startedAt()); currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_INTERNAL",
"Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
} }
} }
private static class NoSpreadsheetException extends RuntimeException {
NoSpreadsheetException(String message) { super(message); }
}
private File findSpreadsheetFile() throws IOException { private File findSpreadsheetFile() throws IOException {
try (Stream<Path> files = Files.list(Paths.get(importDir))) { try (Stream<Path> files = Files.list(Paths.get(importDir))) {
return files return files
@@ -138,7 +147,7 @@ public class MassImportService {
return name.endsWith(".ods") || name.endsWith(".xlsx") || name.endsWith(".xls"); return name.endsWith(".ods") || name.endsWith(".xlsx") || name.endsWith(".xls");
}) })
.findFirst() .findFirst()
.orElseThrow(() -> new RuntimeException( .orElseThrow(() -> new NoSpreadsheetException(
"Keine Tabellendatei (.ods/.xlsx/.xls) in " + importDir + " gefunden!")) "Keine Tabellendatei (.ods/.xlsx/.xls) in " + importDir + " gefunden!"))
.toFile(); .toFile();
} }

View File

@@ -70,14 +70,20 @@ class MassImportServiceTest {
assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.IDLE); assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.IDLE);
} }
@Test
void getStatus_hasStatusCode_IMPORT_IDLE_byDefault() {
assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_IDLE");
}
// ─── runImportAsync ─────────────────────────────────────────────────────── // ─── runImportAsync ───────────────────────────────────────────────────────
@Test @Test
void runImportAsync_setsFailedStatus_whenImportDirectoryDoesNotExist() { void runImportAsync_setsFailedStatus_whenImportDirectoryDoesNotExist() {
// /import directory doesn't exist in test environment → findSpreadsheetFile throws // /import directory doesn't exist in test environment → IOException → IMPORT_FAILED_INTERNAL
service.runImportAsync(); service.runImportAsync();
assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.FAILED); assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.FAILED);
assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_FAILED_INTERNAL");
} }
@Test @Test
@@ -93,10 +99,19 @@ class MassImportServiceTest {
assertThat(service.getStatus().message()).contains(tempDir.toString()); assertThat(service.getStatus().message()).contains(tempDir.toString());
} }
@Test
void runImportAsync_setsStatusCode_IMPORT_FAILED_NO_SPREADSHEET_whenDirIsEmpty(@TempDir Path tempDir) {
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
service.runImportAsync();
assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_FAILED_NO_SPREADSHEET");
}
@Test @Test
void runImportAsync_throwsConflict_whenAlreadyRunning() { void runImportAsync_throwsConflict_whenAlreadyRunning() {
MassImportService.ImportStatus running = new MassImportService.ImportStatus( MassImportService.ImportStatus running = new MassImportService.ImportStatus(
MassImportService.State.RUNNING, "Running...", 0, LocalDateTime.now()); MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, LocalDateTime.now());
ReflectionTestUtils.setField(service, "currentStatus", running); ReflectionTestUtils.setField(service, "currentStatus", running);
assertThatThrownBy(() -> service.runImportAsync()) assertThatThrownBy(() -> service.runImportAsync())

View File

@@ -40,6 +40,22 @@ class AdminControllerTest {
@MockitoBean ThumbnailBackfillService thumbnailBackfillService; @MockitoBean ThumbnailBackfillService thumbnailBackfillService;
@MockitoBean CustomUserDetailsService customUserDetailsService; @MockitoBean CustomUserDetailsService customUserDetailsService;
// ─── GET /api/admin/import-status ─────────────────────────────────────────
@Test
@WithMockUser(authorities = "ADMIN")
void importStatus_returns200_withStatusCode_whenAdmin() throws Exception {
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
when(massImportService.getStatus()).thenReturn(status);
mockMvc.perform(get("/api/admin/import-status"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.state").value("IDLE"))
.andExpect(jsonPath("$.statusCode").value("IMPORT_IDLE"))
.andExpect(jsonPath("$.processed").value(0));
}
@Test @Test
void backfillVersions_returns401_whenUnauthenticated() throws Exception { void backfillVersions_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/admin/backfill-versions")) mockMvc.perform(post("/api/admin/backfill-versions"))