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:
1146
docs/specs/transkriptions-richtlinien-spec.html
Normal file
1146
docs/specs/transkriptions-richtlinien-spec.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page, userEvent } from 'vitest/browser';
|
||||||
|
import BulkDocumentEditLayout from './BulkDocumentEditLayout.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeFile(name: string): File {
|
||||||
|
return new File(['content'], name, { type: 'application/pdf' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addFilesViaInput(container: HTMLElement, files: File[]): Promise<void> {
|
||||||
|
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
if (!input) throw new Error('No file input found — is BulkDropZone visible?');
|
||||||
|
await userEvent.upload(input, files);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BulkDocumentEditLayout', () => {
|
||||||
|
it('N=0: shows BulkDropZone', async () => {
|
||||||
|
render(BulkDocumentEditLayout, {});
|
||||||
|
await expect.element(page.getByTestId('bulk-drop-zone')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('N=1: file-switcher-strip and per-file scope card are absent', async () => {
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [makeFile('doc.pdf')]);
|
||||||
|
expect(container.querySelector('[data-testid="file-switcher-strip"]')).toBeNull();
|
||||||
|
expect(container.querySelector('[data-variant="per-file"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('N=5: file-switcher-strip and per-file scope card are both present', async () => {
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [
|
||||||
|
makeFile('a.pdf'),
|
||||||
|
makeFile('b.pdf'),
|
||||||
|
makeFile('c.pdf'),
|
||||||
|
makeFile('d.pdf'),
|
||||||
|
makeFile('e.pdf')
|
||||||
|
]);
|
||||||
|
expect(container.querySelector('[data-testid="file-switcher-strip"]')).not.toBeNull();
|
||||||
|
expect(container.querySelector('[data-variant="per-file"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removing middle file preserves order of remaining files', async () => {
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [
|
||||||
|
makeFile('file0.pdf'),
|
||||||
|
makeFile('file1.pdf'),
|
||||||
|
makeFile('file2.pdf')
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Remove the chip for file1 via its "Entfernen" remove button (second × button)
|
||||||
|
const removeButtons = container.querySelectorAll<HTMLButtonElement>(
|
||||||
|
'[data-testid="file-switcher-strip"] button[aria-label="Entfernen"]'
|
||||||
|
);
|
||||||
|
expect(removeButtons.length).toBe(3);
|
||||||
|
removeButtons[1].click(); // remove file1
|
||||||
|
|
||||||
|
// Wait for Svelte to flush the DOM update
|
||||||
|
await vi.waitFor(
|
||||||
|
() => {
|
||||||
|
const chips = container.querySelectorAll(
|
||||||
|
'[data-testid="file-switcher-strip"] [data-chip-id]'
|
||||||
|
);
|
||||||
|
expect(chips.length).toBe(2);
|
||||||
|
expect(chips[0].textContent?.trim()).toContain('file0');
|
||||||
|
expect(chips[1].textContent?.trim()).toContain('file2');
|
||||||
|
},
|
||||||
|
{ timeout: 1000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save calls fetch twice for 12 files (2 chunks of 10)', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ created: [], updated: [], errors: [] })
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
// Also stub goto to prevent navigation errors in test
|
||||||
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
const files = Array.from({ length: 12 }, (_, i) => makeFile(`f${i}.pdf`));
|
||||||
|
await addFilesViaInput(container, files);
|
||||||
|
|
||||||
|
const saveBtn = container.querySelector(
|
||||||
|
'button[data-testid="bulk-save-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
expect(saveBtn).not.toBeNull();
|
||||||
|
saveBtn.click();
|
||||||
|
|
||||||
|
// Wait for async save to complete
|
||||||
|
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2), { timeout: 3000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ let {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
data-testid="scope-card"
|
data-testid="scope-card"
|
||||||
|
data-variant={variant}
|
||||||
class="rounded-sm border p-4
|
class="rounded-sm border p-4
|
||||||
{variant === 'per-file'
|
{variant === 'per-file'
|
||||||
? 'border-brand-mint bg-brand-mint/10'
|
? 'border-brand-mint bg-brand-mint/10'
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ let {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid="bulk-save-btn"
|
||||||
onclick={onSave}
|
onclick={onSave}
|
||||||
class="min-h-[44px] rounded-sm bg-brand-navy px-6 text-sm font-bold tracking-widest text-white uppercase hover:bg-brand-navy/90"
|
class="min-h-[44px] rounded-sm bg-brand-navy px-6 text-sm font-bold tracking-widest text-white uppercase hover:bg-brand-navy/90"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user