feat(frontend): wire progress bar, upload zone, and file replace into enrich page
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m35s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Failing after 2m54s
CI / Unit & Component Tests (pull_request) Failing after 2m31s
CI / OCR Service Tests (pull_request) Successful in 32s
CI / Backend Unit Tests (pull_request) Failing after 2m43s

- Required-fields progress bar (Pflichtfelder) with role="progressbar" ARIA tracks
  Titel, Datum, and Absender live via bound props from child components
- Left panel shows UploadZone for PLACEHOLDER documents (no filePath); after upload
  invalidates 'app:document' to transition to PDF viewer without page reload
- AbortController powers the cancel button during upload
- "Datei ersetzen" ghost button lives in a thin toolbar above the PDF viewer
- dateIso and currentTitle are now bound from WhoWhenSection/DescriptionSection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-18 14:07:23 +02:00
parent 93fc8696fd
commit 836d30e262

View File

@@ -1,9 +1,12 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { invalidate } from '$app/navigation';
import { onMount, onDestroy, untrack } from 'svelte';
import { createFileLoader } from '$lib/hooks/useFileLoader.svelte';
import { m } from '$lib/paraglide/messages.js';
import { countRequiredFilled } from '$lib/utils/requiredFields';
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
import UploadZone from '$lib/components/document/UploadZone.svelte';
import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte';
import DescriptionSection from '$lib/components/document/DescriptionSection.svelte';
@@ -36,6 +39,54 @@ onDestroy(() => fileLoader.destroy());
let tags = $state(untrack(() => doc.tags ?? []));
let senderId = $state(untrack(() => doc.sender?.id ?? ''));
let selectedReceivers = $state(untrack(() => doc.receivers ?? []));
// Progress bar state — bound from child components
let dateIso = $state(untrack(() => doc.documentDate ?? ''));
let currentTitle = $state(untrack(() => doc.title ?? ''));
const requiredFilled = $derived(countRequiredFilled(currentTitle, dateIso, senderId));
const requiredPct = $derived((requiredFilled / 3) * 100);
// Upload state machine
let isUploading = $state(false);
let isDragging = $state(false);
let uploadError = $state<string | null>(null);
let abortController = $state<AbortController | null>(null);
async function handleFile(file: File) {
uploadError = null;
isUploading = true;
const controller = new AbortController();
abortController = controller;
try {
const formData = new FormData();
formData.append('file', file);
const res = await fetch(`/api/documents/${doc.id}/file`, {
method: 'POST',
body: formData,
signal: controller.signal
});
if (!res.ok) throw new Error('Upload fehlgeschlagen');
await invalidate('app:document');
} catch (e) {
if ((e as Error).name === 'AbortError') return;
uploadError = 'Upload fehlgeschlagen. Bitte erneut versuchen.';
} finally {
isUploading = false;
abortController = null;
}
}
function cancelUpload() {
abortController?.abort();
isUploading = false;
}
async function handleReplaceFile(e: Event) {
const file = (e.currentTarget as HTMLInputElement).files?.[0];
if (!file) return;
await handleFile(file);
}
</script>
<svelte:head>
@@ -67,20 +118,61 @@ let selectedReceivers = $state(untrack(() => doc.receivers ?? []));
</p>
</div>
<!-- Required-fields progress bar -->
<div class="flex items-center gap-3 border-b border-line bg-surface px-6 py-1.5">
<span class="text-[9px] font-bold tracking-widest text-ink-3 uppercase">Pflichtfelder</span>
<div
class="h-0.5 flex-1 rounded-full bg-line"
role="progressbar"
aria-valuenow={requiredFilled}
aria-valuemin={0}
aria-valuemax={3}
aria-label="Pflichtfelder"
>
<div
class="h-full rounded-full bg-brand-navy transition-all duration-300"
style="width:{requiredPct}%"
></div>
</div>
<span class="text-[10px] font-bold text-brand-navy">{requiredFilled} / 3</span>
</div>
<!-- Main content -->
<div class="flex flex-1 overflow-hidden">
<!-- Left: PDF preview (60%) -->
<div class="relative flex-[6] overflow-hidden border-r border-line">
<DocumentViewer
doc={doc}
fileUrl={fileLoader.fileUrl}
isLoading={fileLoader.isLoading}
error={fileLoader.fileError}
bind:annotateMode={annotateMode}
bind:activeAnnotationId={activeAnnotationId}
bind:activeAnnotationPage={activeAnnotationPage}
onAnnotationClick={() => {}}
/>
<!-- Left: PDF preview / upload zone (60%) -->
<div class="relative flex flex-[6] flex-col overflow-hidden border-r border-line">
{#if !doc.filePath}
<UploadZone
filename={doc.originalFilename}
isUploading={isUploading}
bind:isDragging={isDragging}
error={uploadError}
onFile={handleFile}
onCancel={cancelUpload}
/>
{:else}
<!-- Datei ersetzen toolbar (ghost button above PDF) -->
<div class="flex shrink-0 items-center border-b border-white/10 bg-pdf-bg px-4 py-1.5">
<label
class="ml-auto flex min-h-[44px] cursor-pointer items-center text-xs font-bold tracking-widest text-white/40 uppercase transition-colors hover:text-white/70"
>
Datei ersetzen
<input type="file" class="sr-only" onchange={handleReplaceFile} />
</label>
</div>
<div class="relative flex-1 overflow-hidden">
<DocumentViewer
doc={doc}
fileUrl={fileLoader.fileUrl}
isLoading={fileLoader.isLoading}
error={fileLoader.fileError}
bind:annotateMode={annotateMode}
bind:activeAnnotationId={activeAnnotationId}
bind:activeAnnotationPage={activeAnnotationPage}
onAnnotationClick={() => {}}
/>
</div>
{/if}
</div>
<!-- Right: form (40%) -->
@@ -102,13 +194,17 @@ let selectedReceivers = $state(untrack(() => doc.receivers ?? []));
<WhoWhenSection
bind:senderId={senderId}
bind:selectedReceivers={selectedReceivers}
bind:dateIso={dateIso}
initialDateIso={doc.documentDate ?? ''}
initialLocation={doc.location ?? ''}
initialSenderName={doc.sender
? doc.sender.displayName
: ''}
initialSenderName={doc.sender ? doc.sender.displayName : ''}
/>
<DescriptionSection
bind:tags={tags}
bind:currentTitle={currentTitle}
initialTitle={doc.title ?? ''}
titleRequired={true}
/>
<DescriptionSection bind:tags={tags} initialTitle={doc.title ?? ''} titleRequired={true} />
</form>
<!-- Skip form (outside main form to avoid nesting) -->