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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-30 09:42:44 +02:00
parent 559b522507
commit 9996055cac
5 changed files with 219 additions and 1 deletions

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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<typeof setInterval> | 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() {
</p>
{/if}
</div>
<!-- Mass import -->
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-1 font-sans text-sm font-bold text-ink">{m.admin_system_import_heading()}</h2>
<p class="mb-4 text-sm text-ink-2">{m.admin_system_import_description()}</p>
{#if importStatus?.state === 'RUNNING'}
<p class="text-sm text-ink-2">{m.admin_system_import_status_running()}</p>
{:else if importStatus?.state === 'DONE'}
<p class="mb-4 rounded-sm border border-green-200 bg-green-50 p-3 text-sm text-green-700">
{m.admin_system_import_status_done({ count: importStatus.processed })}
</p>
<button
data-import-trigger
onclick={triggerImport}
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
>
{m.admin_system_import_btn_retry()}
</button>
{:else if importStatus?.state === 'FAILED'}
<p class="mb-4 rounded-sm border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{m.admin_system_import_status_failed({ message: importStatus.message })}
</p>
<button
data-import-trigger
onclick={triggerImport}
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
>
{m.admin_system_import_btn_retry()}
</button>
{:else}
{#if importStatus !== null}
<p class="mb-4 text-sm text-ink-2">{m.admin_system_import_status_idle()}</p>
{/if}
<button
data-import-trigger
onclick={triggerImport}
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
>
{m.admin_system_import_btn_start()}
</button>
{/if}
</div>
</div>
</div>

View File

@@ -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<HTMLButtonElement>('[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();
});
});