feat(bulk-upload): add BulkDocumentEditLayout component with save handler

State-owner for the bulk upload flow:
- N=0: full-panel BulkDropZone
- N=1: title + shared metadata (no switcher/scope cards)
- N≥2: FileSwitcherStrip + per-file ScopeCard + shared ScopeCard
Save handler chunks files at 10/request, POSTs to /api/documents/quick-upload
with typed metadata JSON part, tracks progress, redirects to /documents.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-24 17:57:33 +02:00
parent edd96b05fe
commit 3a6a70a1f7
4 changed files with 291 additions and 0 deletions

View File

@@ -0,0 +1,190 @@
<script lang="ts">
import { SvelteMap } from 'svelte/reactivity';
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import BulkDropZone from './BulkDropZone.svelte';
import FileSwitcherStrip from './FileSwitcherStrip.svelte';
import type { FileEntry } from './FileSwitcherStrip.svelte';
import ScopeCard from './ScopeCard.svelte';
import UploadSaveBar from './UploadSaveBar.svelte';
import WhoWhenSection from './WhoWhenSection.svelte';
import DescriptionSection from './DescriptionSection.svelte';
import { bulkTitleFromFilename } from '$lib/utils/filename';
import type { Tag } from '$lib/components/TagInput.svelte';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
let {
initialSenderId = '',
initialSenderName = '',
initialReceivers = []
}: {
initialSenderId?: string;
initialSenderName?: string;
initialReceivers?: Person[];
} = $props();
// --- File state ---
let files = new SvelteMap<string, FileEntry>();
let activeId = $state<string | null>(null);
let chunkProgress = $state<{ done: number; total: number } | undefined>(undefined);
// --- Shared metadata ---
let senderId = $state(untrack(() => initialSenderId));
let selectedReceivers = $state<Person[]>(untrack(() => initialReceivers));
let dateIso = $state('');
let tags = $state<Tag[]>([]);
// --- Derived ---
const isMulti = $derived(files.size >= 2);
const activeFile = $derived(activeId ? files.get(activeId) : null);
// --- Handlers ---
function addFiles(newFiles: File[]) {
for (const file of newFiles) {
const id = crypto.randomUUID();
const title = bulkTitleFromFilename(file.name);
files.set(id, { id, file, title, status: 'idle' });
if (!activeId) activeId = id;
}
}
function removeFile(id: string) {
files.delete(id);
if (activeId === id) {
activeId = files.keys().next().value ?? null;
}
}
function setTitle(id: string, title: string) {
const entry = files.get(id);
if (entry) files.set(id, { ...entry, title });
}
async function save() {
const entries = Array.from(files.values());
const chunkSize = 10;
const chunks: FileEntry[][] = [];
for (let i = 0; i < entries.length; i += chunkSize) {
chunks.push(entries.slice(i, i + chunkSize));
}
chunkProgress = { done: 0, total: chunks.length };
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
const formData = new FormData();
chunk.forEach((entry) => formData.append('files', entry.file));
const metadata = {
titles: chunk.map((e) => e.title),
senderId: senderId || null,
receiverIds: selectedReceivers.map((r) => r.id),
documentDate: dateIso || null
};
formData.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
await fetch('/api/documents/quick-upload', { method: 'POST', body: formData });
chunkProgress = { done: i + 1, total: chunks.length };
}
goto('/documents');
}
</script>
{#if files.size === 0}
<!-- N=0: full-panel drop zone -->
<div class="flex min-h-[400px] flex-col">
<BulkDropZone onFilesAdded={addFiles} />
</div>
{:else}
<div class="mx-auto flex max-w-7xl flex-col gap-0 px-4 py-6">
{#if isMulti}
<FileSwitcherStrip
files={Array.from(files.values())}
activeId={activeId ?? ''}
onSelect={(id) => (activeId = id)}
onRemove={removeFile}
/>
{/if}
<div class="flex gap-6">
<!-- Left: PDF preview -->
<div class="hidden w-1/2 lg:block">
{#if activeFile}
<div
class="flex h-[600px] flex-col items-center justify-center rounded-sm bg-gray-900 text-white/40"
>
<span class="text-sm">{activeFile.file.name}</span>
<span class="mt-1 text-xs">Vorschau nach Upload verfügbar</span>
</div>
{/if}
</div>
<!-- Right: metadata -->
<div class="flex flex-1 flex-col gap-4">
{#if isMulti}
<!-- Per-file scope card: title -->
<ScopeCard variant="per-file" data-variant="per-file">
{#if activeFile}
<label class="block">
<span class="mb-1 block text-xs font-medium text-brand-navy/70">Titel</span>
<input
type="text"
value={activeFile.title}
oninput={(e) =>
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
class="border-brand-sand block w-full rounded-sm border p-2 text-sm focus:border-brand-mint focus:outline-none"
/>
</label>
{/if}
</ScopeCard>
<!-- Shared scope card: metadata -->
<ScopeCard variant="shared" count={files.size} data-variant="shared">
<WhoWhenSection
bind:senderId={senderId}
bind:selectedReceivers={selectedReceivers}
bind:dateIso={dateIso}
initialSenderName={initialSenderName}
/>
<DescriptionSection bind:tags={tags} hideTitle />
</ScopeCard>
{:else}
<!-- N=1: no scope cards, just the fields directly -->
{#if activeFile}
<div class="border-brand-sand rounded-sm border bg-white p-4 shadow-sm">
<label class="block">
<span class="mb-1 block text-xs font-medium text-brand-navy/70">Titel</span>
<input
type="text"
value={activeFile.title}
oninput={(e) =>
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
class="border-brand-sand block w-full rounded-sm border p-2 text-sm focus:border-brand-mint focus:outline-none"
/>
</label>
</div>
{/if}
<div class="border-brand-sand rounded-sm border bg-white p-4 shadow-sm">
<WhoWhenSection
bind:senderId={senderId}
bind:selectedReceivers={selectedReceivers}
bind:dateIso={dateIso}
initialSenderName={initialSenderName}
/>
<DescriptionSection bind:tags={tags} hideTitle />
</div>
{/if}
</div>
</div>
<UploadSaveBar
fileCount={files.size}
chunkProgress={chunkProgress}
onSave={save}
onDiscard={() => {
files.clear();
activeId = null;
chunkProgress = undefined;
}}
/>
</div>
{/if}