Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m54s
CI / OCR Service Tests (pull_request) Successful in 39s
CI / Backend Unit Tests (pull_request) Failing after 2m56s
CI / Unit & Component Tests (push) Failing after 3m6s
CI / Backend Unit Tests (push) Failing after 2m56s
CI / OCR Service Tests (push) Successful in 34s
Felix C2 — `BatchMetadataRequest` controller now uses `@Valid` so future @Size/etc. annotations on the record actually fire. Felix C3 — Auto-clear `$effect` in `+layout.svelte` reads `bulkSelectionStore.size` inside `untrack()` so the effect only re-fires on route change, not on every checkbox toggle. Felix C4 — `BulkDocumentEditLayout` edit-mode hydration loop now lives inside `onMount` (not at top-level script) so the SvelteMap mutation is unambiguously tied to instance lifecycle, matching the pattern used by `WhoWhenSection`/`DescriptionSection` after the cycle-2 fix. Felix C5 — Replaced fully-qualified `java.util.LinkedHashSet` in `DocumentController` with a top-of-file import. Sara coverage — six new spec files / blocks pin the cycle-1 and cycle-2 behaviours that were previously untested: - `WhoWhenSection.svelte.spec.ts` — onMount seeding from initialDateIso / initialLocation; doesn't stomp parent-bound dateIso; hideDate / editMode branch - `DescriptionSection.svelte.spec.ts` — onMount seeding from initialTitle / initialDocumentLocation; doesn't stomp parent-bound values; archive-box / archive-folder fields visible only in editMode - `BulkSelectionBar.svelte.spec.ts` — Esc-scope guard tests for `<dialog>` open and `aria-expanded` popover present - `BulkDocumentEditLayout.svelte.spec.ts` — topbar reads "Massenbearbeitung" + "werden bearbeitet" in edit mode (not the upload-flavoured "hochladen"/"werden erstellt" copy) - `DocumentControllerTest.patchBulk_returns400_whenArchiveBoxExceeds255Chars` — pins the @Size validator on archiveBox via the @Valid wiring Refs #225, PR #331 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
540 lines
17 KiB
Svelte
540 lines
17 KiB
Svelte
<script lang="ts">
|
|
import { SvelteMap } from 'svelte/reactivity';
|
|
import { goto } from '$app/navigation';
|
|
import { onDestroy, onMount, 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';
|
|
import ScopeCard from './ScopeCard.svelte';
|
|
import UploadSaveBar from './UploadSaveBar.svelte';
|
|
import WhoWhenSection from './WhoWhenSection.svelte';
|
|
import DescriptionSection from './DescriptionSection.svelte';
|
|
import PdfViewer from '$lib/components/PdfViewer.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'];
|
|
|
|
// Mirrors the backend `DocumentBatchSummary` JSON shape one-to-one — the route
|
|
// passes the parsed `/api/documents/batch-metadata` response straight in, so
|
|
// the field names must match what the backend actually serializes (id, not
|
|
// documentId). The FileEntry built from each summary still uses both `id` and
|
|
// `documentId` so the save handler can drive the PATCH payload by UUID.
|
|
export type BulkEditEntry = {
|
|
id: string;
|
|
title: string;
|
|
pdfUrl: string;
|
|
};
|
|
|
|
// Optional — not available in unit tests that don't provide CONFIRM_KEY context.
|
|
let _confirmService: ConfirmService | null;
|
|
try {
|
|
_confirmService = getConfirmService();
|
|
} catch {
|
|
_confirmService = null;
|
|
}
|
|
|
|
let {
|
|
mode = 'upload',
|
|
initialSenderId = '',
|
|
initialSenderName = '',
|
|
initialReceivers = [],
|
|
initialEditEntries = []
|
|
}: {
|
|
mode?: 'upload' | 'edit';
|
|
initialSenderId?: string;
|
|
initialSenderName?: string;
|
|
initialReceivers?: Person[];
|
|
initialEditEntries?: BulkEditEntry[];
|
|
} = $props();
|
|
|
|
// --- File state ---
|
|
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`. Wrapped in onMount so
|
|
// the SvelteMap mutation is unambiguously tied to instance lifecycle, not to
|
|
// the script body's first execution (Felix C4 cycle 3).
|
|
onMount(() => {
|
|
if (mode !== 'edit') return;
|
|
for (const entry of untrack(() => initialEditEntries)) {
|
|
files.set(entry.id, {
|
|
id: entry.id,
|
|
documentId: entry.id,
|
|
title: entry.title,
|
|
status: 'idle',
|
|
previewUrl: entry.pdfUrl
|
|
});
|
|
if (!activeId) activeId = entry.id;
|
|
}
|
|
});
|
|
|
|
// --- Derived ---
|
|
const isMulti = $derived(files.size >= 2);
|
|
const activeFile = $derived(activeId ? files.get(activeId) : null);
|
|
|
|
// --- File management ---
|
|
function addFiles(newFiles: File[]) {
|
|
for (const file of newFiles) {
|
|
const id = crypto.randomUUID();
|
|
const title = bulkTitleFromFilename(file.name);
|
|
const previewUrl = URL.createObjectURL(file);
|
|
files.set(id, { id, file, title, status: 'idle', previewUrl });
|
|
if (!activeId) activeId = id;
|
|
}
|
|
}
|
|
|
|
function removeFile(id: string) {
|
|
const entry = files.get(id);
|
|
if (entry?.previewUrl) URL.revokeObjectURL(entry.previewUrl);
|
|
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 });
|
|
}
|
|
|
|
function discardAll() {
|
|
for (const entry of files.values()) {
|
|
if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl);
|
|
}
|
|
files.clear();
|
|
activeId = null;
|
|
chunkProgress = undefined;
|
|
}
|
|
|
|
async function handleDiscard() {
|
|
if (_confirmService) {
|
|
const ok = await _confirmService.confirm({
|
|
title: m.bulk_discard_all(),
|
|
body: m.bulk_discard_confirm(),
|
|
destructive: true
|
|
});
|
|
if (!ok) return;
|
|
}
|
|
if (mode === 'edit') {
|
|
// In edit mode the file map IS the user's bulk selection — discarding
|
|
// must clear the upstream store and bounce back to the list, otherwise
|
|
// the user is left on /documents/bulk-edit with an empty form and a
|
|
// stale count in the bottom bar (issue #225 Bulk-Edit Panel table).
|
|
bulkSelectionStore.clear();
|
|
discardAll();
|
|
await goto('/documents');
|
|
return;
|
|
}
|
|
discardAll();
|
|
}
|
|
|
|
onDestroy(() => {
|
|
for (const entry of files.values()) {
|
|
if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl);
|
|
}
|
|
});
|
|
|
|
// --- 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;
|
|
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 };
|
|
|
|
let hadErrors = false;
|
|
for (let i = 0; i < chunks.length; i++) {
|
|
const chunk = chunks[i];
|
|
const formData = new FormData();
|
|
chunk.forEach((entry) => entry.file && 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,
|
|
tagNames: tags.map((t) => t.name)
|
|
};
|
|
formData.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
|
|
// Raw fetch is intentional: SvelteKit form actions can't stream chunked
|
|
// FormData with per-chunk progress. Session cookie is sent automatically
|
|
// by the browser for same-origin requests.
|
|
try {
|
|
const res = await fetch('/api/documents/quick-upload', { method: 'POST', body: formData });
|
|
const body = await res.json().catch(() => ({ errors: [] }));
|
|
const errorFilenames = new Set<string>(
|
|
(body.errors ?? []).map((err: { filename: string }) => err.filename)
|
|
);
|
|
if (!res.ok || errorFilenames.size > 0) {
|
|
hadErrors = true;
|
|
for (const entry of chunk) {
|
|
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' });
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
hadErrors = true;
|
|
for (const entry of chunk) {
|
|
const e = files.get(entry.id);
|
|
if (e) files.set(entry.id, { ...e, status: 'error' });
|
|
}
|
|
}
|
|
chunkProgress = { done: i + 1, total: chunks.length };
|
|
}
|
|
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)">
|
|
<!-- Topbar -->
|
|
<div class="flex shrink-0 items-center gap-3 border-b border-line bg-surface px-6 py-3">
|
|
<a
|
|
href="/documents"
|
|
class="flex items-center gap-1.5 text-xs font-bold tracking-widest text-ink-3 uppercase hover:text-ink"
|
|
>
|
|
<svg
|
|
class="h-3.5 w-3.5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
|
/>
|
|
</svg>
|
|
{m.btn_back_to_overview()}
|
|
</a>
|
|
<span class="text-ink-3" aria-hidden="true">·</span>
|
|
<span class="font-serif text-sm font-bold text-ink">
|
|
{#if mode === 'edit'}
|
|
{m.bulk_edit_topbar_title()}
|
|
{:else}
|
|
{isMulti ? m.bulk_title_multi() : m.bulk_title_single()}
|
|
{/if}
|
|
</span>
|
|
{#if isMulti}
|
|
<span class="ml-auto flex items-center gap-3">
|
|
<span class="rounded-[2px] bg-accent px-2 py-0.5 text-xs font-bold text-primary">
|
|
{#if mode === 'edit'}
|
|
{m.bulk_edit_count_pill({ count: files.size })}
|
|
{:else}
|
|
{m.bulk_count_pill({ count: files.size })}
|
|
{/if}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
data-testid="discard-all-btn"
|
|
onclick={handleDiscard}
|
|
class="text-xs font-medium text-red-600/70 hover:text-red-700"
|
|
>
|
|
{m.bulk_discard_all()}
|
|
</button>
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Split panel -->
|
|
<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 mode === 'upload' && files.size === 0}
|
|
<!-- N=0: centred drop-zone box fills the panel (upload only) -->
|
|
<BulkDropZone onFilesAdded={addFiles} />
|
|
{: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} />
|
|
{/if}
|
|
</div>
|
|
{#if isMulti}
|
|
<!-- File switcher strip pinned to bottom of left panel -->
|
|
<FileSwitcherStrip
|
|
files={Array.from(files.values())}
|
|
activeId={activeId ?? ''}
|
|
onSelect={(id) => (activeId = id)}
|
|
onRemove={removeFile}
|
|
/>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Right: metadata form (45%) -->
|
|
<div class="flex flex-[45] flex-col overflow-hidden">
|
|
<!-- Scrollable form area — greyed out and non-interactive when no files selected -->
|
|
<div
|
|
class="flex-1 space-y-4 overflow-y-auto p-4 transition-opacity"
|
|
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.
|
|
No aria-label — role=note + the visible text content is
|
|
self-describing; an aria-label would override that text for
|
|
AT users on non-DE locales. -->
|
|
<div
|
|
role="note"
|
|
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}
|
|
{#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>
|
|
|
|
<ScopeCard variant="shared" count={files.size}>
|
|
<WhoWhenSection
|
|
bind:senderId={senderId}
|
|
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'}
|
|
/>
|
|
</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">
|
|
{#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
|
|
bind:senderId={senderId}
|
|
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'}
|
|
/>
|
|
{/if}
|
|
|
|
{#if partialSaved}
|
|
<div
|
|
role="alert"
|
|
data-testid="bulk-edit-partial-failure"
|
|
class="rounded-sm border border-danger/40 bg-danger/10 px-4 py-3 text-sm text-danger"
|
|
>
|
|
<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>
|
|
|
|
<!-- Action bar: always visible at bottom of right panel -->
|
|
<UploadSaveBar
|
|
fileCount={files.size}
|
|
chunkProgress={chunkProgress}
|
|
onSave={save}
|
|
onDiscard={handleDiscard}
|
|
disabled={saving}
|
|
editMode={mode === 'edit'}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|