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(); + }); +});