diff --git a/src/lib/components/BeaArchiveProcessing.svelte b/src/lib/components/BeaArchiveProcessing.svelte new file mode 100644 index 0000000..94274c6 --- /dev/null +++ b/src/lib/components/BeaArchiveProcessing.svelte @@ -0,0 +1,332 @@ + + + + +{#if compact} + +

+ {completedCount} von {jobs.length} Dateien bereit, {processingCount} in Bearbeitung, + {failedJobs.length} fehlgeschlagen. +

+ +
+ +
+

+ {#if processingCount > 0} + {processingCount} ZIP-Datei{processingCount === 1 ? '' : 'en'} + {processingCount === 1 ? ' wird' : ' werden'} noch geöffnet + {:else} + {failedJobs.length} ZIP-Datei{failedJobs.length === 1 ? '' : 'en'} konnte{failedJobs.length === + 1 + ? '' + : 'n'} nicht geöffnet werden + {/if} +

+

+ {jobs + .filter((job) => + processingCount > 0 ? job.status === 'processing' : job.status === 'error' + ) + .map((job) => job.file.name) + .join(', ')} +

+
+
+

+ Verarbeitung nur in Ihrem Browser +

+
+ + {#if failedJobs.length > 0} + + {#each failedJobs as job (job.id)} +
+

+ {job.file.name} konnte nicht geöffnet werden. +

+
+ + +
+
+ {/each} +
+ {/if} +
+{:else} + +

+ {completedCount} von {jobs.length} Dateien bereit, {processingCount} in Bearbeitung, + {failedJobs.length} fehlgeschlagen. +

+ + + + + {#if processingCount > 0} + beA-Archive werden vorbereitet + {:else if failedJobs.length > 0 && completedCount === 0} + Archive konnten nicht vorbereitet werden + {:else} + Archive sind bereit + {/if} + + + Die Dateien werden ausschließlich in Ihrem Browser geöffnet und nicht hochgeladen. + + + + +
+
+ {completedCount} von {jobs.length} Dateien bereit + {#if processingCount > 0} + Bitte kurz warten + {/if} +
+
+
+
+
+ + +
+
+{/if} + + diff --git a/src/routes/layout.css b/src/routes/layout.css index 1af2ab8..b1c3cbf 100644 --- a/src/routes/layout.css +++ b/src/routes/layout.css @@ -33,6 +33,7 @@ --accent: #d6dce5; --accent-foreground: oklch(0.205 0 0); --destructive: #ff0027; + --success: #4e8a72; --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); --ring: oklch(0.708 0 0); @@ -68,6 +69,7 @@ --accent: oklch(0.269 0 0); --accent-foreground: oklch(0.985 0 0); --destructive: #ff0027; + --success: #79c4a3; --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.556 0 0); @@ -105,6 +107,7 @@ --color-input: var(--input); --color-border: var(--border); --color-destructive: var(--destructive); + --color-success: var(--success); --color-accent-foreground: var(--accent-foreground); --color-accent: var(--accent); --color-muted-foreground: var(--muted-foreground); diff --git a/src/routes/tools/bea/+page.svelte b/src/routes/tools/bea/+page.svelte index 3bb9133..e61e0a2 100644 --- a/src/routes/tools/bea/+page.svelte +++ b/src/routes/tools/bea/+page.svelte @@ -3,6 +3,9 @@ import Button from '$lib/components/ui/button.svelte'; import ProcessedZipArchiveEditor from '$lib/components/ProcessedZipArchiveEditor.svelte'; import ZipDropzone from '$lib/components/ZipDropzone.svelte'; + import BeaArchiveProcessing, { + type ZipProcessingJob + } from '$lib/components/BeaArchiveProcessing.svelte'; import { extractZipArchives, mergeProcessedZipArchive, @@ -16,7 +19,9 @@ let selectedZipFiles: File[] = $state([]); let processedZipFiles: ProcessedZipArchive[] = $state([]); - let pendingZipCount = $state(0); + let zipJobs: ZipProcessingJob[] = $state([]); + let pendingZipCount = $derived(zipJobs.filter((job) => job.status === 'processing').length); + let failedZipCount = $derived(zipJobs.filter((job) => job.status === 'error').length); let thumbnailWidth = $state(200); let thumbnailWidthHydrated = $state(false); let archiveGeneration = 0; @@ -48,34 +53,67 @@ localStorage.setItem(THUMBNAIL_WIDTH_STORAGE_KEY, String(thumbnailWidth)); }); + const updateZipJob = (jobId: string, update: Partial) => { + zipJobs = zipJobs.map((job) => (job.id === jobId ? { ...job, ...update } : job)); + }; + const handleFilesSelected = (files: File[]) => { selectedZipFiles = [...selectedZipFiles, ...files]; - for (const file of files) { - void processZipFile(file, archiveGeneration); + const jobs = files.map((file) => ({ + id: crypto.randomUUID(), + file, + status: 'processing' as const + })); + zipJobs = [...zipJobs, ...jobs]; + + for (const job of jobs) { + void processZipFile(job.id, job.file, archiveGeneration); } }; - const processZipFile = async (file: File, generation: number) => { - pendingZipCount += 1; + const processZipFile = async (jobId: string, file: File, generation: number) => { + updateZipJob(jobId, { status: 'processing', error: undefined, archiveCount: undefined }); try { const processedZipArchives = await extractZipArchives(file); - if (generation !== archiveGeneration) { + if (generation !== archiveGeneration || !zipJobs.some((job) => job.id === jobId)) { return; } processedZipFiles = [...processedZipFiles, ...processedZipArchives]; + updateZipJob(jobId, { + status: 'complete', + archiveCount: processedZipArchives.length + }); } catch (error) { console.error(`Failed to process ${file.name}`, error); - } finally { - if (generation === archiveGeneration) { - pendingZipCount -= 1; + + if (generation === archiveGeneration && zipJobs.some((job) => job.id === jobId)) { + updateZipJob(jobId, { + status: 'error', + error: 'Prüfen Sie, ob die Datei ein gültiges beA-ZIP-Archiv ist.' + }); } } }; + const retryZipFile = (jobId: string) => { + const job = zipJobs.find((candidate) => candidate.id === jobId); + if (!job || job.status === 'processing') return; + + void processZipFile(job.id, job.file, archiveGeneration); + }; + + const removeZipFile = (jobId: string) => { + const job = zipJobs.find((candidate) => candidate.id === jobId); + if (!job || job.status === 'processing') return; + + zipJobs = zipJobs.filter((candidate) => candidate.id !== jobId); + selectedZipFiles = selectedZipFiles.filter((file) => file !== job.file); + }; + const updateProcessedZipFile = (index: number, archive: ProcessedZipArchive) => { processedZipFiles = processedZipFiles.map((existingArchive, existingIndex) => existingIndex === index ? archive : existingArchive @@ -97,7 +135,7 @@ archiveGeneration += 1; selectedZipFiles = []; processedZipFiles = []; - pendingZipCount = 0; + zipJobs = []; }; @@ -166,12 +204,15 @@ class="flex min-h-[calc(-180px+100vh)] w-full grow flex-col justify-center gap-4 px-4 py-6" >
- {#if pendingZipCount > 0} -
- Noch {pendingZipCount} ZIP-Datei{pendingZipCount === 1 ? '' : 'en'} in Bearbeitung. -
+ {#if processedZipFiles.length === 0} + + {:else if pendingZipCount > 0 || failedZipCount > 0} + {/if} {#if processedZipFiles.length > 0} diff --git a/tests/e2e/toolbox.spec.ts b/tests/e2e/toolbox.spec.ts index cee2467..cf947f9 100644 --- a/tests/e2e/toolbox.spec.ts +++ b/tests/e2e/toolbox.spec.ts @@ -76,6 +76,8 @@ const trackPageErrors = (page: Page) => { }; const pdfDropzone = (page: Page) => page.getByRole('button', { name: 'PDF-Dateien hinzufügen' }); +const zipDropzone = (page: Page) => + page.getByRole('button', { name: 'beA-ZIP-Dateien hinzufügen' }); test('start page exposes only working tools and forwards a dropped ZIP to beA', async ({ page @@ -97,6 +99,28 @@ test('start page exposes only working tools and forwards a dropped ZIP to beA', expect(pageErrors).toEqual([]); }); +test('beA reports an invalid ZIP and lets the user remove it', async ({ page }) => { + const pageErrors = trackPageErrors(page); + + await page.goto('/tools/bea'); + await dropFiles(page, zipDropzone(page), [ + { + name: 'defektes-archiv.zip', + mimeType: 'application/zip', + buffer: Buffer.from('kein gueltiges ZIP-Archiv') + } + ]); + + await expect(page.getByText('defektes-archiv.zip', { exact: true })).toBeVisible(); + await expect(page.getByText('Konnte nicht geöffnet werden.')).toBeVisible(); + await expect(page.getByRole('status')).toContainText('1 fehlgeschlagen'); + await expect(page.getByRole('button', { name: 'Erneut versuchen' })).toBeVisible(); + + await page.getByRole('button', { name: 'Entfernen' }).click(); + await expect(zipDropzone(page)).toBeVisible(); + expect(pageErrors).toEqual([]); +}); + test('short tool pages keep the footer flush with the viewport bottom', async ({ page }) => { const expectFooterAtViewportBottom = async (viewport: { width: number; height: number }) => { await page.setViewportSize(viewport);