feat(bulk-edit): extend BulkDocumentEditLayout with mode="edit"

- New FieldLabelBadge component (additive / replace variants, WCAG AA contrast)
- WhoWhenSection: hideDate prop, editMode prop renders badges next to sender
  and receivers, hides the meta_location field
- DescriptionSection: editMode prop renders badges next to tags and archive
  fields; new bindable archiveBox / archiveFolder inputs only in editMode
- PersonTypeahead: optional badge prop forwards to FieldLabelBadge
- FileSwitcherStrip FileEntry: file is now optional, documentId added so
  edit-mode entries reference an existing document by UUID
- BulkDocumentEditLayout: mode prop branches drop zone / read-only title /
  callout / save handler. Edit save chunks 500 IDs per PATCH, stops on chunk
  failure with retry, marks per-document errors as chips, clears the bulk
  selection store on full success.

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-25 15:16:06 +02:00
parent d4f32ed5d4
commit fa5dc43864
8 changed files with 610 additions and 110 deletions

View File

@@ -5,6 +5,7 @@ import { onDestroy, untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { getConfirmService } from '$lib/services/confirm.svelte.js';
import type { ConfirmService } from '$lib/services/confirm.svelte.js';
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
import BulkDropZone from './BulkDropZone.svelte';
import FileSwitcherStrip from './FileSwitcherStrip.svelte';
import type { FileEntry } from './FileSwitcherStrip.svelte';
@@ -19,6 +20,12 @@ import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
export type BulkEditEntry = {
documentId: string;
title: string;
pdfUrl: string;
};
// Optional — not available in unit tests that don't provide CONFIRM_KEY context.
let _confirmService: ConfirmService | null;
try {
@@ -28,13 +35,17 @@ try {
}
let {
mode = 'upload',
initialSenderId = '',
initialSenderName = '',
initialReceivers = []
initialReceivers = [],
initialEditEntries = []
}: {
mode?: 'upload' | 'edit';
initialSenderId?: string;
initialSenderName?: string;
initialReceivers?: Person[];
initialEditEntries?: BulkEditEntry[];
} = $props();
// --- File state ---
@@ -42,12 +53,35 @@ let files = new SvelteMap<string, FileEntry>();
let activeId = $state<string | null>(null);
let chunkProgress = $state<{ done: number; total: number } | undefined>(undefined);
let saving = $state(false);
// Partial-failure surface: when set, the last save aborted at chunk N of M.
let partialSaved = $state<{ done: number; total: number } | null>(null);
// --- Shared metadata ---
let senderId = $state(untrack(() => initialSenderId));
let selectedReceivers = $state<Person[]>(untrack(() => initialReceivers));
let dateIso = $state('');
let tags = $state<Tag[]>([]);
// Bulk-edit only — replace-on-non-blank semantics.
let documentLocation = $state('');
let archiveBox = $state('');
let archiveFolder = $state('');
// Hydrate edit-mode entries on mount. The IDs in bulkSelectionStore drive the
// fetch upstream in the route — by the time this layout mounts, the metadata
// has already been resolved into `initialEditEntries`.
if (mode === 'edit') {
for (const entry of untrack(() => initialEditEntries)) {
const id = entry.documentId; // reuse documentId as the local FileEntry key
files.set(id, {
id,
documentId: entry.documentId,
title: entry.title,
status: 'idle',
previewUrl: entry.pdfUrl
});
if (!activeId) activeId = id;
}
}
// --- Derived ---
const isMulti = $derived(files.size >= 2);
@@ -105,10 +139,8 @@ onDestroy(() => {
}
});
// --- Save ---
async function save() {
if (saving) return;
saving = true;
// --- Save (upload mode) ---
async function saveUpload() {
const entries = Array.from(files.values());
// 10 files per request keeps multipart bodies well under typical reverse-proxy limits (e.g. nginx default 1 MB client_max_body_size per PDF).
const chunkSize = 10;
@@ -122,7 +154,7 @@ async function save() {
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
const formData = new FormData();
chunk.forEach((entry) => formData.append('files', entry.file));
chunk.forEach((entry) => entry.file && formData.append('files', entry.file));
const metadata = {
titles: chunk.map((e) => e.title),
senderId: senderId || null,
@@ -143,8 +175,8 @@ async function save() {
if (!res.ok || errorFilenames.size > 0) {
hadErrors = true;
for (const entry of chunk) {
// When backend names specific files, mark only those; otherwise mark all.
const isError = errorFilenames.size > 0 ? errorFilenames.has(entry.file.name) : true;
const filename = entry.file?.name;
const isError = errorFilenames.size > 0 && filename ? errorFilenames.has(filename) : true;
if (isError) {
const e = files.get(entry.id);
if (e) files.set(entry.id, { ...e, status: 'error' });
@@ -160,9 +192,97 @@ async function save() {
}
chunkProgress = { done: i + 1, total: chunks.length };
}
saving = false;
if (!hadErrors) goto('/documents');
}
// --- Save (edit mode) ---
async function saveBulkEdit() {
const entries = Array.from(files.values());
const ids = entries.map((e) => e.documentId).filter((x): x is string => !!x);
// PATCH cap matches backend: 500 IDs per request. Sequential, stop on chunk
// failure so the user sees a deterministic "X of N saved" outcome.
const chunkSize = 500;
const chunks: string[][] = [];
for (let i = 0; i < ids.length; i += chunkSize) {
chunks.push(ids.slice(i, i + chunkSize));
}
chunkProgress = { done: 0, total: chunks.length };
partialSaved = null;
const dto = {
tagNames: tags.map((t) => t.name),
senderId: senderId || null,
receiverIds: selectedReceivers.map((r) => r.id),
documentLocation: documentLocation || null,
archiveBox: archiveBox || null,
archiveFolder: archiveFolder || null
};
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
try {
const res = await fetch('/api/documents/bulk', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...dto, documentIds: chunk })
});
if (!res.ok) {
// Network/server failure: the chunk did not apply. Mark its entries
// as errored, surface partial-save state, and stop.
for (const id of chunk) {
const e = files.get(id);
if (e) files.set(id, { ...e, status: 'error' });
}
partialSaved = { done: i, total: chunks.length };
return;
}
const body = (await res.json().catch(() => null)) as {
updated: number;
errors: { id: string; message: string }[];
} | null;
if (body && body.errors && body.errors.length > 0) {
for (const err of body.errors) {
const e = files.get(err.id);
if (e) files.set(err.id, { ...e, status: 'error' });
}
}
} catch {
for (const id of chunk) {
const e = files.get(id);
if (e) files.set(id, { ...e, status: 'error' });
}
partialSaved = { done: i, total: chunks.length };
return;
}
chunkProgress = { done: i + 1, total: chunks.length };
}
const stillErrored = Array.from(files.values()).some((e) => e.status === 'error');
if (!stillErrored) {
bulkSelectionStore.clear();
goto('/documents');
}
}
async function save() {
if (saving) return;
saving = true;
try {
if (mode === 'edit') {
await saveBulkEdit();
} else {
await saveUpload();
}
} finally {
saving = false;
}
}
async function retrySave() {
partialSaved = null;
await save();
}
</script>
<div class="fixed inset-x-0 bottom-0 flex flex-col" style="top: var(--header-height)">
@@ -213,11 +333,11 @@ async function save() {
<div class="flex flex-1 overflow-hidden">
<!-- Left: PDF preview / drop zone (55%) -->
<div class="relative flex flex-[55] flex-col overflow-hidden border-r border-line bg-pdf-bg">
{#if files.size === 0}
<!-- N=0: centred drop-zone box fills the panel -->
{#if mode === 'upload' && files.size === 0}
<!-- N=0: centred drop-zone box fills the panel (upload only) -->
<BulkDropZone onFilesAdded={addFiles} />
{:else}
<!-- N≥1: real PDF preview via local blob URL -->
{:else if files.size > 0}
<!-- PDF preview: blob URL in upload mode, server URL in edit mode -->
<div class="relative flex-1 overflow-hidden">
{#if activeFile}
<PdfViewer url={activeFile.previewUrl} />
@@ -243,22 +363,44 @@ async function save() {
class:opacity-60={files.size === 0}
class:pointer-events-none={files.size === 0}
>
{#if mode === 'edit'}
<!-- Onboarding callout: tells the user that empty fields are skipped
and that tags/receivers are added rather than replaced. -->
<div
role="note"
aria-label="Hinweis zur Massenbearbeitung"
data-testid="bulk-edit-callout"
class="rounded-sm border border-accent/40 bg-accent/15 px-4 py-3 text-sm text-ink-2"
>
{m.bulk_edit_hint()}
</div>
{/if}
{#if isMulti}
<!-- N≥2: per-file card (title) + shared card (metadata) -->
<ScopeCard variant="per-file">
{#if activeFile}
<label class="block">
<span class="mb-1 block text-xs font-medium tracking-widest text-ink-2 uppercase">
{m.form_label_title()} <span class="text-danger">*</span>
</span>
<input
type="text"
value={activeFile.title}
oninput={(e) =>
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
class="block w-full rounded-sm border border-line bg-surface p-2 text-sm focus:border-accent focus:outline-none"
/>
</label>
{#if mode === 'edit'}
<div data-testid="readonly-title">
<span class="mb-1 block text-xs font-medium tracking-widest text-ink-2 uppercase">
{m.form_label_title()}
</span>
<p class="font-serif text-base text-ink">{activeFile.title}</p>
</div>
{:else}
<label class="block">
<span class="mb-1 block text-xs font-medium tracking-widest text-ink-2 uppercase">
{m.form_label_title()} <span class="text-danger">*</span>
</span>
<input
type="text"
value={activeFile.title}
oninput={(e) =>
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
class="block w-full rounded-sm border border-line bg-surface p-2 text-sm focus:border-accent focus:outline-none"
/>
</label>
{/if}
{/if}
</ScopeCard>
@@ -268,33 +410,51 @@ async function save() {
bind:selectedReceivers={selectedReceivers}
bind:dateIso={dateIso}
initialSenderName={initialSenderName}
hideDate={mode === 'edit'}
editMode={mode === 'edit'}
/>
<DescriptionSection
bind:tags={tags}
bind:documentLocation={documentLocation}
bind:archiveBox={archiveBox}
bind:archiveFolder={archiveFolder}
hideTitle
editMode={mode === 'edit'}
/>
<DescriptionSection bind:tags={tags} hideTitle />
</ScopeCard>
{:else}
<!-- N=0 (disabled placeholder) or N=1 (active): title + shared form -->
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<label class="block">
<span class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.form_label_title()} <span class="text-danger">*</span>
</span>
{#if activeFile}
<input
type="text"
value={activeFile.title}
oninput={(e) =>
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
{:else}
<input
type="text"
disabled
placeholder="—"
class="block w-full rounded border border-line p-2 text-sm text-ink-3 shadow-sm"
/>
{/if}
</label>
{#if mode === 'edit' && activeFile}
<div data-testid="readonly-title">
<span class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.form_label_title()}
</span>
<p class="font-serif text-base text-ink">{activeFile.title}</p>
</div>
{:else}
<label class="block">
<span class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.form_label_title()} <span class="text-danger">*</span>
</span>
{#if activeFile}
<input
type="text"
value={activeFile.title}
oninput={(e) =>
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
{:else}
<input
type="text"
disabled
placeholder="—"
class="block w-full rounded border border-line p-2 text-sm text-ink-3 shadow-sm"
/>
{/if}
</label>
{/if}
</div>
<WhoWhenSection
@@ -302,8 +462,39 @@ async function save() {
bind:selectedReceivers={selectedReceivers}
bind:dateIso={dateIso}
initialSenderName={initialSenderName}
hideDate={mode === 'edit'}
editMode={mode === 'edit'}
/>
<DescriptionSection bind:tags={tags} hideTitle />
<DescriptionSection
bind:tags={tags}
bind:documentLocation={documentLocation}
bind:archiveBox={archiveBox}
bind:archiveFolder={archiveFolder}
hideTitle
editMode={mode === 'edit'}
/>
{/if}
{#if partialSaved}
<div
role="alert"
data-testid="bulk-edit-partial-failure"
class="rounded-sm border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700"
>
<p class="font-medium">
{m.bulk_edit_save_partial({
done: partialSaved.done,
total: partialSaved.total
})}
</p>
<button
type="button"
onclick={retrySave}
class="mt-2 inline-flex items-center bg-primary px-4 py-2 text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
>
{m.bulk_edit_retry()}
</button>
</div>
{/if}
</div>