- {#if files.size === 0}
-
+ {#if mode === 'upload' && files.size === 0}
+
- {:else}
-
+ {:else if files.size > 0}
+
{#if activeFile}
@@ -243,22 +363,44 @@ async function save() {
class:opacity-60={files.size === 0}
class:pointer-events-none={files.size === 0}
>
+ {#if mode === 'edit'}
+
+
+ {m.bulk_edit_hint()}
+
+ {/if}
+
{#if isMulti}
{#if activeFile}
-
+ {#if mode === 'edit'}
+
+
+ {m.form_label_title()}
+
+
{activeFile.title}
+
+ {:else}
+
+ {/if}
{/if}
@@ -268,33 +410,51 @@ async function save() {
bind:selectedReceivers={selectedReceivers}
bind:dateIso={dateIso}
initialSenderName={initialSenderName}
+ hideDate={mode === 'edit'}
+ editMode={mode === 'edit'}
+ />
+
-
{:else}
-
+
+ {/if}
+
+ {#if partialSaved}
+
+
+ {m.bulk_edit_save_partial({
+ done: partialSaved.done,
+ total: partialSaved.total
+ })}
+
+
+
{/if}
diff --git a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts
index 1b24627d..1f96216f 100644
--- a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts
+++ b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts
@@ -312,3 +312,190 @@ describe('BulkDocumentEditLayout', () => {
);
});
});
+
+// ─── mode="edit" ─────────────────────────────────────────────────────────────
+
+describe('BulkDocumentEditLayout — mode="edit"', () => {
+ const editEntry = (i: number) => ({
+ documentId: `doc-${i}`,
+ title: `Brief ${i}`,
+ pdfUrl: `/api/documents/doc-${i}/file`
+ });
+
+ it('does not render the BulkDropZone in edit mode', async () => {
+ const { container } = render(BulkDocumentEditLayout, {
+ mode: 'edit',
+ initialEditEntries: [editEntry(1)]
+ });
+ expect(container.querySelector('[data-testid="bulk-drop-zone"]')).toBeNull();
+ });
+
+ it('renders the onboarding callout with role=note in edit mode', async () => {
+ render(BulkDocumentEditLayout, {
+ mode: 'edit',
+ initialEditEntries: [editEntry(1)]
+ });
+ const callout = page.getByTestId('bulk-edit-callout');
+ await expect.element(callout).toBeInTheDocument();
+ await expect.element(callout).toHaveAttribute('role', 'note');
+ });
+
+ it('renders read-only title display (no input) in edit mode', async () => {
+ const { container } = render(BulkDocumentEditLayout, {
+ mode: 'edit',
+ initialEditEntries: [editEntry(1)]
+ });
+ expect(container.querySelector('[data-testid="readonly-title"]')).not.toBeNull();
+ // Per-file ScopeCard absent at N=1 — title rendered in the single card
+ const titleInput = container.querySelector('input[type="text"][value="Brief 1"]');
+ expect(titleInput).toBeNull();
+ });
+
+ it('hides the date field via WhoWhenSection hideDate prop', async () => {
+ const { container } = render(BulkDocumentEditLayout, {
+ mode: 'edit',
+ initialEditEntries: [editEntry(1)]
+ });
+ expect(container.querySelector('[data-testid="who-when-date"]')).toBeNull();
+ });
+
+ it('shows additive badge next to tags label', async () => {
+ const { container } = render(BulkDocumentEditLayout, {
+ mode: 'edit',
+ initialEditEntries: [editEntry(1)]
+ });
+ expect(container.querySelector('[data-testid="field-label-badge-additive"]')).not.toBeNull();
+ });
+
+ it('shows replace badges next to sender and archive fields', async () => {
+ const { container } = render(BulkDocumentEditLayout, {
+ mode: 'edit',
+ initialEditEntries: [editEntry(1)]
+ });
+ const replaceBadges = container.querySelectorAll('[data-testid="field-label-badge-replace"]');
+ // sender + documentLocation + archiveBox + archiveFolder = 4
+ expect(replaceBadges.length).toBeGreaterThanOrEqual(4);
+ });
+
+ it('shows the archiveBox and archiveFolder bulk-only inputs', async () => {
+ const { container } = render(BulkDocumentEditLayout, {
+ mode: 'edit',
+ initialEditEntries: [editEntry(1)]
+ });
+ expect(container.querySelector('[data-testid="description-archive-box"]')).not.toBeNull();
+ expect(container.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull();
+ });
+
+ it('save calls PATCH /api/documents/bulk in edit mode', async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ updated: 2, errors: [] })
+ });
+ vi.stubGlobal('fetch', mockFetch);
+
+ const { container } = render(BulkDocumentEditLayout, {
+ mode: 'edit',
+ initialEditEntries: [editEntry(1), editEntry(2)]
+ });
+
+ const saveBtn = container.querySelector(
+ 'button[data-testid="bulk-save-btn"]'
+ ) as HTMLButtonElement;
+ expect(saveBtn).not.toBeNull();
+ saveBtn.click();
+
+ await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
+ const [url, init] = mockFetch.mock.calls[0];
+ expect(url).toBe('/api/documents/bulk');
+ expect(init.method).toBe('PATCH');
+ const body = JSON.parse(init.body);
+ expect(body.documentIds).toEqual(['doc-1', 'doc-2']);
+ });
+
+ it('chunks IDs into 500-sized PATCH requests', async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ updated: 500, errors: [] })
+ });
+ vi.stubGlobal('fetch', mockFetch);
+
+ const entries = Array.from({ length: 1100 }, (_, i) => editEntry(i));
+ const { container } = render(BulkDocumentEditLayout, {
+ mode: 'edit',
+ initialEditEntries: entries
+ });
+
+ const saveBtn = container.querySelector(
+ 'button[data-testid="bulk-save-btn"]'
+ ) as HTMLButtonElement;
+ saveBtn.click();
+
+ await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(3), { timeout: 5000 });
+ expect(JSON.parse(mockFetch.mock.calls[0][1].body).documentIds.length).toBe(500);
+ expect(JSON.parse(mockFetch.mock.calls[1][1].body).documentIds.length).toBe(500);
+ expect(JSON.parse(mockFetch.mock.calls[2][1].body).documentIds.length).toBe(100);
+ });
+
+ it('stops on chunk failure and shows the partial-failure alert with retry', async () => {
+ const mockFetch = vi
+ .fn()
+ .mockResolvedValueOnce({ ok: true, json: async () => ({ updated: 500, errors: [] }) })
+ .mockResolvedValueOnce({ ok: false, json: async () => ({ code: 'INTERNAL_ERROR' }) });
+ vi.stubGlobal('fetch', mockFetch);
+
+ const entries = Array.from({ length: 1100 }, (_, i) => editEntry(i));
+ const { container } = render(BulkDocumentEditLayout, {
+ mode: 'edit',
+ initialEditEntries: entries
+ });
+
+ const saveBtn = container.querySelector(
+ 'button[data-testid="bulk-save-btn"]'
+ ) as HTMLButtonElement;
+ saveBtn.click();
+
+ await vi.waitFor(
+ () => {
+ const alert = container.querySelector('[data-testid="bulk-edit-partial-failure"]');
+ expect(alert).not.toBeNull();
+ },
+ { timeout: 5000 }
+ );
+ // Should have called twice — chunks 0 and 1 — but not the third.
+ expect(mockFetch).toHaveBeenCalledTimes(2);
+ expect(vi.mocked(goto)).not.toHaveBeenCalled();
+ });
+
+ it('marks per-document error chips when service returns errors[]', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ updated: 1,
+ errors: [{ id: 'doc-2', message: 'Sender not found' }]
+ })
+ })
+ );
+
+ const { container } = render(BulkDocumentEditLayout, {
+ mode: 'edit',
+ initialEditEntries: [editEntry(1), editEntry(2)]
+ });
+
+ const saveBtn = container.querySelector(
+ 'button[data-testid="bulk-save-btn"]'
+ ) as HTMLButtonElement;
+ saveBtn.click();
+
+ await vi.waitFor(
+ () => {
+ const errorChip = container.querySelector(
+ '[data-testid="file-switcher-strip"] [data-chip-id="doc-2"][data-status="error"]'
+ );
+ expect(errorChip).not.toBeNull();
+ },
+ { timeout: 3000 }
+ );
+ });
+});
diff --git a/frontend/src/lib/components/document/DescriptionSection.svelte b/frontend/src/lib/components/document/DescriptionSection.svelte
index 27e7442e..dcde5a7a 100644
--- a/frontend/src/lib/components/document/DescriptionSection.svelte
+++ b/frontend/src/lib/components/document/DescriptionSection.svelte
@@ -1,31 +1,45 @@
@@ -67,40 +81,78 @@ const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || curren
-
{m.form_label_tags()}
+
+ {m.form_label_tags()}
+ {#if editMode}{/if}
+
t.name).join(',')} />
-
-
-
-
-
+ {#if !editMode}
+
+
+
+
+
+ {/if}
-
+
+ >{m.form_label_archive_location()}
+ {#if editMode}
{/if}
+
{m.form_helper_archive_location()}
+
+ {#if editMode}
+
+
+
+
+
+
+
+
+
+
+
+ {/if}
diff --git a/frontend/src/lib/components/document/FieldLabelBadge.svelte b/frontend/src/lib/components/document/FieldLabelBadge.svelte
new file mode 100644
index 00000000..ac59e552
--- /dev/null
+++ b/frontend/src/lib/components/document/FieldLabelBadge.svelte
@@ -0,0 +1,16 @@
+
+
+
+ {text}
+
diff --git a/frontend/src/lib/components/document/FieldLabelBadge.svelte.spec.ts b/frontend/src/lib/components/document/FieldLabelBadge.svelte.spec.ts
new file mode 100644
index 00000000..9895e0c0
--- /dev/null
+++ b/frontend/src/lib/components/document/FieldLabelBadge.svelte.spec.ts
@@ -0,0 +1,30 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+import FieldLabelBadge from './FieldLabelBadge.svelte';
+
+afterEach(() => cleanup());
+
+describe('FieldLabelBadge', () => {
+ it('renders the additive variant text', async () => {
+ render(FieldLabelBadge, { variant: 'additive' });
+ await expect.element(page.getByTestId('field-label-badge-additive')).toBeInTheDocument();
+ await expect
+ .element(page.getByTestId('field-label-badge-additive'))
+ .toHaveTextContent('+ wird hinzugefügt');
+ });
+
+ it('renders the replace variant text', async () => {
+ render(FieldLabelBadge, { variant: 'replace' });
+ await expect
+ .element(page.getByTestId('field-label-badge-replace'))
+ .toHaveTextContent('wird ersetzt');
+ });
+
+ it('uses text-gray-600 for WCAG-AA contrast on muted backgrounds', async () => {
+ render(FieldLabelBadge, { variant: 'replace' });
+ await expect
+ .element(page.getByTestId('field-label-badge-replace'))
+ .toHaveClass(/text-gray-600/);
+ });
+});
diff --git a/frontend/src/lib/components/document/FileSwitcherStrip.svelte b/frontend/src/lib/components/document/FileSwitcherStrip.svelte
index 8816d1a1..2ee272da 100644
--- a/frontend/src/lib/components/document/FileSwitcherStrip.svelte
+++ b/frontend/src/lib/components/document/FileSwitcherStrip.svelte
@@ -4,7 +4,11 @@ import { m } from '$lib/paraglide/messages.js';
export interface FileEntry {
id: string;
- file: File;
+ /** Present in upload mode only. Edit mode entries reference an existing
+ * document by `documentId` and have no local file blob. */
+ file?: File;
+ /** Present in edit mode only — the server-side document UUID being edited. */
+ documentId?: string;
title: string;
status: 'idle' | 'error';
previewUrl: string;
diff --git a/frontend/src/lib/components/document/WhoWhenSection.svelte b/frontend/src/lib/components/document/WhoWhenSection.svelte
index 679ecc84..6bfb2bb7 100644
--- a/frontend/src/lib/components/document/WhoWhenSection.svelte
+++ b/frontend/src/lib/components/document/WhoWhenSection.svelte
@@ -2,6 +2,7 @@
import { untrack } from 'svelte';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
+import FieldLabelBadge from './FieldLabelBadge.svelte';
import { isoToGerman, handleGermanDateInput } from '$lib/utils/date';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
@@ -16,7 +17,9 @@ let {
initialLocation = '',
initialSenderName = '',
suggestedDateIso = '',
- suggestedSenderName = ''
+ suggestedSenderName = '',
+ hideDate = false,
+ editMode = false
}: {
senderId?: string;
selectedReceivers?: Person[];
@@ -26,6 +29,8 @@ let {
initialSenderName?: string;
suggestedDateIso?: string;
suggestedSenderName?: string;
+ hideDate?: boolean;
+ editMode?: boolean;
} = $props();
let dateDisplay = $state(untrack(() => isoToGerman(initialDateIso)));
@@ -56,60 +61,72 @@ $effect(() => {
-
-
-
-
-
- {#if dateInvalid}
-
{m.form_date_error()}
- {/if}
-
+ {#if !hideDate}
+
+
+
+
+
+ {#if dateInvalid}
+
{m.form_date_error()}
+ {/if}
+
+ {/if}
-
+
-
{m.form_label_receivers()}
+
+ {m.form_label_receivers()}
+ {#if editMode}{/if}
+
-
-
-
-
-
+ {#if !editMode}
+
+
+
+
+
+ {/if}