From 9996055cac4302bf7a67216285489a3a4c9b6283 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Mon, 30 Mar 2026 09:42:44 +0200
Subject: [PATCH] feat(admin): mass import card on system tab with live status
polling
Adds a new card on the System tab that triggers the existing
POST /api/admin/trigger-import endpoint. Status is polled every 2 s
while RUNNING and stops automatically on DONE or FAILED.
IDLE/RUNNING/DONE/FAILED states each render distinct UI feedback.
Co-Authored-By: Claude Sonnet 4.6
---
frontend/messages/de.json | 8 ++
frontend/messages/en.json | 8 ++
frontend/messages/es.json | 8 ++
frontend/src/routes/admin/system/+page.svelte | 92 ++++++++++++++++
.../routes/admin/system/page.svelte.spec.ts | 104 +++++++++++++++++-
5 files changed, 219 insertions(+), 1 deletion(-)
diff --git a/frontend/messages/de.json b/frontend/messages/de.json
index fdd201d6..c344e8e3 100644
--- a/frontend/messages/de.json
+++ b/frontend/messages/de.json
@@ -273,6 +273,14 @@
"admin_system_backfill_hashes_description": "Berechnet den SHA-256-Hash für alle bereits hochgeladenen Dokumente, die noch keinen Hash haben. Dadurch werden Annotationen korrekt mit ihrer Dateiversion verknüpft und wieder angezeigt.",
"admin_system_backfill_hashes_btn": "Datei-Hashes berechnen",
"admin_system_backfill_hashes_success": "{count} Dokumente wurden aktualisiert.",
+ "admin_system_import_heading": "Massenimport",
+ "admin_system_import_description": "Importiert Dokumente und Metadaten aus der Importdatei im /import-Verzeichnis.",
+ "admin_system_import_btn_start": "Import starten",
+ "admin_system_import_btn_retry": "Erneut starten",
+ "admin_system_import_status_idle": "Kein Import gestartet.",
+ "admin_system_import_status_running": "Import läuft…",
+ "admin_system_import_status_done": "Import abgeschlossen – {count} Dokumente verarbeitet.",
+ "admin_system_import_status_failed": "Fehler: {message}",
"comp_expandable_show_more": "Mehr anzeigen",
"comp_expandable_show_less": "Weniger anzeigen",
"error_comment_not_found": "Der Kommentar wurde nicht gefunden.",
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 3c501a2a..7bb2a116 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -273,6 +273,14 @@
"admin_system_backfill_hashes_description": "Computes the SHA-256 hash for all previously uploaded documents that do not have one yet. This ensures annotations are correctly linked to their file version and shown again.",
"admin_system_backfill_hashes_btn": "Compute file hashes",
"admin_system_backfill_hashes_success": "{count} documents were updated.",
+ "admin_system_import_heading": "Mass import",
+ "admin_system_import_description": "Imports documents and metadata from the spreadsheet file in the /import directory.",
+ "admin_system_import_btn_start": "Start import",
+ "admin_system_import_btn_retry": "Start again",
+ "admin_system_import_status_idle": "No import started.",
+ "admin_system_import_status_running": "Import running…",
+ "admin_system_import_status_done": "Import complete – {count} documents processed.",
+ "admin_system_import_status_failed": "Error: {message}",
"comp_expandable_show_more": "Show more",
"comp_expandable_show_less": "Show less",
"error_comment_not_found": "The comment could not be found.",
diff --git a/frontend/messages/es.json b/frontend/messages/es.json
index b65254cf..0f9d93d4 100644
--- a/frontend/messages/es.json
+++ b/frontend/messages/es.json
@@ -273,6 +273,14 @@
"admin_system_backfill_hashes_description": "Calcula el hash SHA-256 para todos los documentos ya subidos que aún no tienen uno. Así las anotaciones se vinculan correctamente a su versión del archivo y vuelven a mostrarse.",
"admin_system_backfill_hashes_btn": "Calcular hashes de archivo",
"admin_system_backfill_hashes_success": "{count} documentos fueron actualizados.",
+ "admin_system_import_heading": "Importación masiva",
+ "admin_system_import_description": "Importa documentos y metadatos desde el archivo en el directorio /import.",
+ "admin_system_import_btn_start": "Iniciar importación",
+ "admin_system_import_btn_retry": "Iniciar de nuevo",
+ "admin_system_import_status_idle": "No hay importación iniciada.",
+ "admin_system_import_status_running": "Importación en curso…",
+ "admin_system_import_status_done": "Importación completada – {count} documentos procesados.",
+ "admin_system_import_status_failed": "Error: {message}",
"comp_expandable_show_more": "Mostrar más",
"comp_expandable_show_less": "Mostrar menos",
"error_comment_not_found": "El comentario no pudo encontrarse.",
diff --git a/frontend/src/routes/admin/system/+page.svelte b/frontend/src/routes/admin/system/+page.svelte
index ddb575c6..463b1677 100644
--- a/frontend/src/routes/admin/system/+page.svelte
+++ b/frontend/src/routes/admin/system/+page.svelte
@@ -6,6 +6,55 @@ let backfillLoading = $state(false);
let backfillHashesResult: number | null = $state(null);
let backfillHashesLoading = $state(false);
+type ImportStatus = {
+ state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED';
+ message: string;
+ processed: number;
+ startedAt: string | null;
+};
+
+let importStatus: ImportStatus | null = $state(null);
+let pollInterval: ReturnType | null = null;
+
+function startPolling() {
+ if (pollInterval) return;
+ pollInterval = setInterval(fetchImportStatus, 2000);
+}
+
+function stopPolling() {
+ if (pollInterval) {
+ clearInterval(pollInterval);
+ pollInterval = null;
+ }
+}
+
+async function fetchImportStatus() {
+ const res = await fetch('/api/admin/import-status');
+ if (res.ok) {
+ importStatus = await res.json();
+ if (importStatus!.state === 'RUNNING') {
+ startPolling();
+ } else {
+ stopPolling();
+ }
+ }
+}
+
+async function triggerImport() {
+ const res = await fetch('/api/admin/trigger-import', { method: 'POST' });
+ if (res.ok) {
+ importStatus = await res.json();
+ if (importStatus!.state === 'RUNNING') {
+ startPolling();
+ }
+ }
+}
+
+$effect(() => {
+ fetchImportStatus();
+ return () => stopPolling();
+});
+
async function backfillVersions() {
backfillLoading = true;
backfillResult = null;
@@ -74,5 +123,48 @@ async function backfillFileHashes() {
{/if}
+
+
+
+
{m.admin_system_import_heading()}
+
{m.admin_system_import_description()}
+
+ {#if importStatus?.state === 'RUNNING'}
+
{m.admin_system_import_status_running()}
+ {:else if importStatus?.state === 'DONE'}
+
+ {m.admin_system_import_status_done({ count: importStatus.processed })}
+
+
+ {:else if importStatus?.state === 'FAILED'}
+
+ {m.admin_system_import_status_failed({ message: importStatus.message })}
+
+
+ {:else}
+ {#if importStatus !== null}
+
{m.admin_system_import_status_idle()}
+ {/if}
+
+ {/if}
+
diff --git a/frontend/src/routes/admin/system/page.svelte.spec.ts b/frontend/src/routes/admin/system/page.svelte.spec.ts
index 4d8e0bb7..df349893 100644
--- a/frontend/src/routes/admin/system/page.svelte.spec.ts
+++ b/frontend/src/routes/admin/system/page.svelte.spec.ts
@@ -1,9 +1,10 @@
-import { afterEach, describe, expect, it } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import Page from './+page.svelte';
afterEach(cleanup);
+afterEach(() => vi.restoreAllMocks());
describe('Admin system page', () => {
it('renders the backfill versions heading', async () => {
@@ -32,3 +33,104 @@ describe('Admin system page', () => {
.toBeInTheDocument();
});
});
+
+describe('Admin system page — mass import card', () => {
+ beforeEach(() => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ state: 'IDLE',
+ message: 'Kein Import gestartet.',
+ processed: 0,
+ startedAt: null
+ })
+ })
+ );
+ });
+
+ it('renders the mass import heading', async () => {
+ render(Page, {});
+ await expect.element(page.getByText(/Massenimport/i)).toBeInTheDocument();
+ });
+
+ it('renders the start import button when idle', async () => {
+ render(Page, {});
+ await expect.element(page.getByRole('button', { name: /Import starten/i })).toBeInTheDocument();
+ });
+
+ it('shows idle status text', async () => {
+ render(Page, {});
+ await expect.element(page.getByText(/Kein Import gestartet/i)).toBeInTheDocument();
+ });
+
+ it('disables the start button and shows running state after click', async () => {
+ const fetchMock = vi
+ .fn()
+ // initial status fetch → IDLE
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ state: 'IDLE',
+ message: 'Kein Import gestartet.',
+ processed: 0,
+ startedAt: null
+ })
+ })
+ // trigger POST → returns RUNNING immediately
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ state: 'RUNNING',
+ message: 'Import läuft...',
+ processed: 0,
+ startedAt: '2026-01-01T10:00:00'
+ })
+ });
+ vi.stubGlobal('fetch', fetchMock);
+
+ render(Page, {});
+ await expect.element(page.getByRole('button', { name: /Import starten/i })).toBeInTheDocument();
+
+ document.querySelector('[data-import-trigger]')!.click();
+
+ await expect.element(page.getByText(/Import läuft/i)).toBeInTheDocument();
+ });
+
+ it('shows done status and retry button after successful import', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ state: 'DONE',
+ message: 'Import abgeschlossen.',
+ processed: 42,
+ startedAt: '2026-01-01T10:00:00'
+ })
+ })
+ );
+ render(Page, {});
+ await expect.element(page.getByText(/42 Dokumente/i)).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: /Erneut starten/i })).toBeInTheDocument();
+ });
+
+ it('shows failed status and retry button on error', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ state: 'FAILED',
+ message: 'Datei nicht gefunden.',
+ processed: 0,
+ startedAt: '2026-01-01T10:00:00'
+ })
+ })
+ );
+ render(Page, {});
+ await expect.element(page.getByText(/Datei nicht gefunden/i)).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: /Erneut starten/i })).toBeInTheDocument();
+ });
+});