diff --git a/frontend/src/lib/components/document/BulkSelectionBar.svelte b/frontend/src/lib/components/document/BulkSelectionBar.svelte
new file mode 100644
index 00000000..5a16897c
--- /dev/null
+++ b/frontend/src/lib/components/document/BulkSelectionBar.svelte
@@ -0,0 +1,46 @@
+
+
+{#if canWrite && count > 0}
+
+
+ {m.bulk_edit_n_selected({ count })}
+
+
+
+
+
+
+{/if}
diff --git a/frontend/src/lib/components/document/BulkSelectionBar.svelte.spec.ts b/frontend/src/lib/components/document/BulkSelectionBar.svelte.spec.ts
new file mode 100644
index 00000000..649220e1
--- /dev/null
+++ b/frontend/src/lib/components/document/BulkSelectionBar.svelte.spec.ts
@@ -0,0 +1,49 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+import { goto } from '$app/navigation';
+import BulkSelectionBar from './BulkSelectionBar.svelte';
+import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
+
+vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
+
+afterEach(() => {
+ cleanup();
+ vi.mocked(goto).mockClear();
+ bulkSelectionStore.clear();
+});
+
+describe('BulkSelectionBar', () => {
+ it('does not render when canWrite is false', async () => {
+ bulkSelectionStore.add('a');
+ render(BulkSelectionBar, { canWrite: false });
+ await expect.element(page.getByTestId('bulk-selection-bar')).not.toBeInTheDocument();
+ });
+
+ it('does not render when selection is empty', async () => {
+ render(BulkSelectionBar, { canWrite: true });
+ await expect.element(page.getByTestId('bulk-selection-bar')).not.toBeInTheDocument();
+ });
+
+ it('renders with the current selection count', async () => {
+ bulkSelectionStore.add('a');
+ bulkSelectionStore.add('b');
+ render(BulkSelectionBar, { canWrite: true });
+ await expect.element(page.getByTestId('bulk-selection-count')).toHaveTextContent('2');
+ });
+
+ it('clear button empties the store', async () => {
+ bulkSelectionStore.add('a');
+ bulkSelectionStore.add('b');
+ render(BulkSelectionBar, { canWrite: true });
+ await page.getByTestId('bulk-clear-all').click();
+ expect(bulkSelectionStore.size).toBe(0);
+ });
+
+ it('Massenbearbeitung navigates to /documents/bulk-edit', async () => {
+ bulkSelectionStore.add('a');
+ render(BulkSelectionBar, { canWrite: true });
+ await page.getByTestId('bulk-edit-open').click();
+ expect(vi.mocked(goto)).toHaveBeenCalledWith('/documents/bulk-edit');
+ });
+});
diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte
index 80159e25..4bc00f64 100644
--- a/frontend/src/routes/documents/+page.svelte
+++ b/frontend/src/routes/documents/+page.svelte
@@ -6,6 +6,8 @@ import { SvelteURLSearchParams } from 'svelte/reactivity';
import SearchFilterBar from '../SearchFilterBar.svelte';
import DocumentList from '../DocumentList.svelte';
import Pagination from '$lib/components/Pagination.svelte';
+import BulkSelectionBar from '$lib/components/document/BulkSelectionBar.svelte';
+import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
import * as m from '$lib/paraglide/messages.js';
let { data } = $props();
@@ -138,6 +140,45 @@ $effect(() => {
}
});
+let editingAll = $state(false);
+
+/**
+ * Fast path: replace the current selection with every document matching the
+ * active filter (across all pages) and jump to the bulk-edit screen. The
+ * /api/documents/ids endpoint is uncapped — chunking happens at PATCH time
+ * inside the bulk-edit page's save handler.
+ */
+async function editAllMatching() {
+ if (editingAll) return;
+ editingAll = true;
+ try {
+ const params = buildSearchParams({
+ q: data.q || '',
+ from: data.from || '',
+ to: data.to || '',
+ senderId: data.senderId || '',
+ receiverId: data.receiverId || '',
+ tags: data.tags || [],
+ sort: '',
+ dir: '',
+ tagQ: data.tagQ || '',
+ tagOp: (data.tagOp as 'AND' | 'OR') || 'AND'
+ });
+ params.delete('sort');
+ params.delete('dir');
+ const res = await fetch(`/api/documents/ids?${params.toString()}`);
+ if (!res.ok) {
+ editingAll = false;
+ return;
+ }
+ const ids: string[] = await res.json();
+ bulkSelectionStore.setAll(ids);
+ await goto('/documents/bulk-edit');
+ } finally {
+ editingAll = false;
+ }
+}
+
// Keep local filter state in sync with server data after navigation completes.
// Guard q: skip overwrite while the user is actively typing.
$effect(() => {
@@ -181,6 +222,20 @@ $effect(() => {
onblur={() => (qFocused = false)}
/>
+ {#if data.canWrite && data.totalElements > 0}
+
+
+
+ {/if}
+
{
+
+
diff --git a/frontend/src/routes/enrich/+page.svelte b/frontend/src/routes/enrich/+page.svelte
index e6457afd..dce475a8 100644
--- a/frontend/src/routes/enrich/+page.svelte
+++ b/frontend/src/routes/enrich/+page.svelte
@@ -99,3 +99,5 @@ const canWrite = $derived(data.canWrite);
{/if}
+
+