feat(frontend): wire progress bar, upload zone, and file replace into enrich page

- 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
committed by marcel
parent 8ed66ae82f
commit c4e1f1e599

View File

@@ -1,9 +1,12 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { invalidate } from '$app/navigation';
import { onMount, onDestroy, untrack } from 'svelte'; import { onMount, onDestroy, untrack } from 'svelte';
import { createFileLoader } from '$lib/hooks/useFileLoader.svelte'; import { createFileLoader } from '$lib/hooks/useFileLoader.svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { countRequiredFilled } from '$lib/utils/requiredFields';
import DocumentViewer from '$lib/components/DocumentViewer.svelte'; import DocumentViewer from '$lib/components/DocumentViewer.svelte';
import UploadZone from '$lib/components/document/UploadZone.svelte';
import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte'; import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte';
import DescriptionSection from '$lib/components/document/DescriptionSection.svelte'; import DescriptionSection from '$lib/components/document/DescriptionSection.svelte';
@@ -36,6 +39,54 @@ onDestroy(() => fileLoader.destroy());
let tags = $state(untrack(() => doc.tags ?? [])); let tags = $state(untrack(() => doc.tags ?? []));
let senderId = $state(untrack(() => doc.sender?.id ?? '')); let senderId = $state(untrack(() => doc.sender?.id ?? ''));
let selectedReceivers = $state(untrack(() => doc.receivers ?? [])); 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> </script>
<svelte:head> <svelte:head>
@@ -67,20 +118,61 @@ let selectedReceivers = $state(untrack(() => doc.receivers ?? []));
</p> </p>
</div> </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 --> <!-- Main content -->
<div class="flex flex-1 overflow-hidden"> <div class="flex flex-1 overflow-hidden">
<!-- Left: PDF preview (60%) --> <!-- Left: PDF preview / upload zone (60%) -->
<div class="relative flex-[6] overflow-hidden border-r border-line"> <div class="relative flex flex-[6] flex-col overflow-hidden border-r border-line">
<DocumentViewer {#if !doc.filePath}
doc={doc} <UploadZone
fileUrl={fileLoader.fileUrl} filename={doc.originalFilename}
isLoading={fileLoader.isLoading} isUploading={isUploading}
error={fileLoader.fileError} bind:isDragging={isDragging}
bind:annotateMode={annotateMode} error={uploadError}
bind:activeAnnotationId={activeAnnotationId} onFile={handleFile}
bind:activeAnnotationPage={activeAnnotationPage} onCancel={cancelUpload}
onAnnotationClick={() => {}} />
/> {: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> </div>
<!-- Right: form (40%) --> <!-- Right: form (40%) -->
@@ -102,13 +194,17 @@ let selectedReceivers = $state(untrack(() => doc.receivers ?? []));
<WhoWhenSection <WhoWhenSection
bind:senderId={senderId} bind:senderId={senderId}
bind:selectedReceivers={selectedReceivers} bind:selectedReceivers={selectedReceivers}
bind:dateIso={dateIso}
initialDateIso={doc.documentDate ?? ''} initialDateIso={doc.documentDate ?? ''}
initialLocation={doc.location ?? ''} initialLocation={doc.location ?? ''}
initialSenderName={doc.sender initialSenderName={doc.sender ? doc.sender.displayName : ''}
? 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> </form>
<!-- Skip form (outside main form to avoid nesting) --> <!-- Skip form (outside main form to avoid nesting) -->