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:
@@ -4,6 +4,7 @@ import type { components } from '$lib/generated/api';
|
|||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { clickOutside } from '$lib/actions/clickOutside';
|
import { clickOutside } from '$lib/actions/clickOutside';
|
||||||
import { createTypeahead } from '$lib/hooks/useTypeahead.svelte';
|
import { createTypeahead } from '$lib/hooks/useTypeahead.svelte';
|
||||||
|
import FieldLabelBadge from './document/FieldLabelBadge.svelte';
|
||||||
type Person = components['schemas']['Person'];
|
type Person = components['schemas']['Person'];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -18,6 +19,7 @@ interface Props {
|
|||||||
autofocus?: boolean;
|
autofocus?: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
restrictToCorrespondentsOf?: string;
|
restrictToCorrespondentsOf?: string;
|
||||||
|
badge?: 'additive' | 'replace';
|
||||||
onchange?: (value: string) => void;
|
onchange?: (value: string) => void;
|
||||||
onfocused?: () => void;
|
onfocused?: () => void;
|
||||||
}
|
}
|
||||||
@@ -34,6 +36,7 @@ let {
|
|||||||
autofocus = false,
|
autofocus = false,
|
||||||
required = false,
|
required = false,
|
||||||
restrictToCorrespondentsOf,
|
restrictToCorrespondentsOf,
|
||||||
|
badge,
|
||||||
onchange,
|
onchange,
|
||||||
onfocused
|
onfocused
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -116,7 +119,7 @@ function selectPerson(person: Person) {
|
|||||||
class={compact
|
class={compact
|
||||||
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
|
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
|
||||||
: 'block text-sm font-medium text-ink-2'}
|
: 'block text-sm font-medium text-ink-2'}
|
||||||
>{label}{#if required}*{/if}</label
|
>{label}{#if required}*{/if}{#if badge}<FieldLabelBadge variant={badge} />{/if}</label
|
||||||
>
|
>
|
||||||
|
|
||||||
<input type="hidden" name={name} bind:value={value} />
|
<input type="hidden" name={name} bind:value={value} />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { onDestroy, untrack } from 'svelte';
|
|||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
||||||
import type { ConfirmService } 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 BulkDropZone from './BulkDropZone.svelte';
|
||||||
import FileSwitcherStrip from './FileSwitcherStrip.svelte';
|
import FileSwitcherStrip from './FileSwitcherStrip.svelte';
|
||||||
import type { FileEntry } 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'];
|
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.
|
// Optional — not available in unit tests that don't provide CONFIRM_KEY context.
|
||||||
let _confirmService: ConfirmService | null;
|
let _confirmService: ConfirmService | null;
|
||||||
try {
|
try {
|
||||||
@@ -28,13 +35,17 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
mode = 'upload',
|
||||||
initialSenderId = '',
|
initialSenderId = '',
|
||||||
initialSenderName = '',
|
initialSenderName = '',
|
||||||
initialReceivers = []
|
initialReceivers = [],
|
||||||
|
initialEditEntries = []
|
||||||
}: {
|
}: {
|
||||||
|
mode?: 'upload' | 'edit';
|
||||||
initialSenderId?: string;
|
initialSenderId?: string;
|
||||||
initialSenderName?: string;
|
initialSenderName?: string;
|
||||||
initialReceivers?: Person[];
|
initialReceivers?: Person[];
|
||||||
|
initialEditEntries?: BulkEditEntry[];
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
// --- File state ---
|
// --- File state ---
|
||||||
@@ -42,12 +53,35 @@ let files = new SvelteMap<string, FileEntry>();
|
|||||||
let activeId = $state<string | null>(null);
|
let activeId = $state<string | null>(null);
|
||||||
let chunkProgress = $state<{ done: number; total: number } | undefined>(undefined);
|
let chunkProgress = $state<{ done: number; total: number } | undefined>(undefined);
|
||||||
let saving = $state(false);
|
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 ---
|
// --- Shared metadata ---
|
||||||
let senderId = $state(untrack(() => initialSenderId));
|
let senderId = $state(untrack(() => initialSenderId));
|
||||||
let selectedReceivers = $state<Person[]>(untrack(() => initialReceivers));
|
let selectedReceivers = $state<Person[]>(untrack(() => initialReceivers));
|
||||||
let dateIso = $state('');
|
let dateIso = $state('');
|
||||||
let tags = $state<Tag[]>([]);
|
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 ---
|
// --- Derived ---
|
||||||
const isMulti = $derived(files.size >= 2);
|
const isMulti = $derived(files.size >= 2);
|
||||||
@@ -105,10 +139,8 @@ onDestroy(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Save ---
|
// --- Save (upload mode) ---
|
||||||
async function save() {
|
async function saveUpload() {
|
||||||
if (saving) return;
|
|
||||||
saving = true;
|
|
||||||
const entries = Array.from(files.values());
|
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).
|
// 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 chunkSize = 10;
|
||||||
@@ -122,7 +154,7 @@ async function save() {
|
|||||||
for (let i = 0; i < chunks.length; i++) {
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
const chunk = chunks[i];
|
const chunk = chunks[i];
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
chunk.forEach((entry) => formData.append('files', entry.file));
|
chunk.forEach((entry) => entry.file && formData.append('files', entry.file));
|
||||||
const metadata = {
|
const metadata = {
|
||||||
titles: chunk.map((e) => e.title),
|
titles: chunk.map((e) => e.title),
|
||||||
senderId: senderId || null,
|
senderId: senderId || null,
|
||||||
@@ -143,8 +175,8 @@ async function save() {
|
|||||||
if (!res.ok || errorFilenames.size > 0) {
|
if (!res.ok || errorFilenames.size > 0) {
|
||||||
hadErrors = true;
|
hadErrors = true;
|
||||||
for (const entry of chunk) {
|
for (const entry of chunk) {
|
||||||
// When backend names specific files, mark only those; otherwise mark all.
|
const filename = entry.file?.name;
|
||||||
const isError = errorFilenames.size > 0 ? errorFilenames.has(entry.file.name) : true;
|
const isError = errorFilenames.size > 0 && filename ? errorFilenames.has(filename) : true;
|
||||||
if (isError) {
|
if (isError) {
|
||||||
const e = files.get(entry.id);
|
const e = files.get(entry.id);
|
||||||
if (e) files.set(entry.id, { ...e, status: 'error' });
|
if (e) files.set(entry.id, { ...e, status: 'error' });
|
||||||
@@ -160,9 +192,97 @@ async function save() {
|
|||||||
}
|
}
|
||||||
chunkProgress = { done: i + 1, total: chunks.length };
|
chunkProgress = { done: i + 1, total: chunks.length };
|
||||||
}
|
}
|
||||||
saving = false;
|
|
||||||
if (!hadErrors) goto('/documents');
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="fixed inset-x-0 bottom-0 flex flex-col" style="top: var(--header-height)">
|
<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">
|
<div class="flex flex-1 overflow-hidden">
|
||||||
<!-- Left: PDF preview / drop zone (55%) -->
|
<!-- Left: PDF preview / drop zone (55%) -->
|
||||||
<div class="relative flex flex-[55] flex-col overflow-hidden border-r border-line bg-pdf-bg">
|
<div class="relative flex flex-[55] flex-col overflow-hidden border-r border-line bg-pdf-bg">
|
||||||
{#if files.size === 0}
|
{#if mode === 'upload' && files.size === 0}
|
||||||
<!-- N=0: centred drop-zone box fills the panel -->
|
<!-- N=0: centred drop-zone box fills the panel (upload only) -->
|
||||||
<BulkDropZone onFilesAdded={addFiles} />
|
<BulkDropZone onFilesAdded={addFiles} />
|
||||||
{:else}
|
{:else if files.size > 0}
|
||||||
<!-- N≥1: real PDF preview via local blob URL -->
|
<!-- PDF preview: blob URL in upload mode, server URL in edit mode -->
|
||||||
<div class="relative flex-1 overflow-hidden">
|
<div class="relative flex-1 overflow-hidden">
|
||||||
{#if activeFile}
|
{#if activeFile}
|
||||||
<PdfViewer url={activeFile.previewUrl} />
|
<PdfViewer url={activeFile.previewUrl} />
|
||||||
@@ -243,22 +363,44 @@ async function save() {
|
|||||||
class:opacity-60={files.size === 0}
|
class:opacity-60={files.size === 0}
|
||||||
class:pointer-events-none={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}
|
{#if isMulti}
|
||||||
<!-- N≥2: per-file card (title) + shared card (metadata) -->
|
<!-- N≥2: per-file card (title) + shared card (metadata) -->
|
||||||
<ScopeCard variant="per-file">
|
<ScopeCard variant="per-file">
|
||||||
{#if activeFile}
|
{#if activeFile}
|
||||||
<label class="block">
|
{#if mode === 'edit'}
|
||||||
<span class="mb-1 block text-xs font-medium tracking-widest text-ink-2 uppercase">
|
<div data-testid="readonly-title">
|
||||||
{m.form_label_title()} <span class="text-danger">*</span>
|
<span class="mb-1 block text-xs font-medium tracking-widest text-ink-2 uppercase">
|
||||||
</span>
|
{m.form_label_title()}
|
||||||
<input
|
</span>
|
||||||
type="text"
|
<p class="font-serif text-base text-ink">{activeFile.title}</p>
|
||||||
value={activeFile.title}
|
</div>
|
||||||
oninput={(e) =>
|
{:else}
|
||||||
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
|
<label class="block">
|
||||||
class="block w-full rounded-sm border border-line bg-surface p-2 text-sm focus:border-accent focus:outline-none"
|
<span class="mb-1 block text-xs font-medium tracking-widest text-ink-2 uppercase">
|
||||||
/>
|
{m.form_label_title()} <span class="text-danger">*</span>
|
||||||
</label>
|
</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}
|
{/if}
|
||||||
</ScopeCard>
|
</ScopeCard>
|
||||||
|
|
||||||
@@ -268,33 +410,51 @@ async function save() {
|
|||||||
bind:selectedReceivers={selectedReceivers}
|
bind:selectedReceivers={selectedReceivers}
|
||||||
bind:dateIso={dateIso}
|
bind:dateIso={dateIso}
|
||||||
initialSenderName={initialSenderName}
|
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>
|
</ScopeCard>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- N=0 (disabled placeholder) or N=1 (active): title + shared form -->
|
<!-- N=0 (disabled placeholder) or N=1 (active): title + shared form -->
|
||||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
<label class="block">
|
{#if mode === 'edit' && activeFile}
|
||||||
<span class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
|
<div data-testid="readonly-title">
|
||||||
{m.form_label_title()} <span class="text-danger">*</span>
|
<span class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
</span>
|
{m.form_label_title()}
|
||||||
{#if activeFile}
|
</span>
|
||||||
<input
|
<p class="font-serif text-base text-ink">{activeFile.title}</p>
|
||||||
type="text"
|
</div>
|
||||||
value={activeFile.title}
|
{:else}
|
||||||
oninput={(e) =>
|
<label class="block">
|
||||||
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
|
<span class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
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"
|
{m.form_label_title()} <span class="text-danger">*</span>
|
||||||
/>
|
</span>
|
||||||
{:else}
|
{#if activeFile}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
disabled
|
value={activeFile.title}
|
||||||
placeholder="—"
|
oninput={(e) =>
|
||||||
class="block w-full rounded border border-line p-2 text-sm text-ink-3 shadow-sm"
|
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"
|
||||||
{/if}
|
/>
|
||||||
</label>
|
{: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>
|
</div>
|
||||||
|
|
||||||
<WhoWhenSection
|
<WhoWhenSection
|
||||||
@@ -302,8 +462,39 @@ async function save() {
|
|||||||
bind:selectedReceivers={selectedReceivers}
|
bind:selectedReceivers={selectedReceivers}
|
||||||
bind:dateIso={dateIso}
|
bind:dateIso={dateIso}
|
||||||
initialSenderName={initialSenderName}
|
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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,31 +1,45 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import TagInput, { type Tag } from '$lib/components/TagInput.svelte';
|
import TagInput, { type Tag } from '$lib/components/TagInput.svelte';
|
||||||
|
import FieldLabelBadge from './FieldLabelBadge.svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
tags = $bindable<Tag[]>([]),
|
tags = $bindable<Tag[]>([]),
|
||||||
currentTitle = $bindable(''),
|
currentTitle = $bindable(''),
|
||||||
|
documentLocation = $bindable(''),
|
||||||
|
archiveBox = $bindable(''),
|
||||||
|
archiveFolder = $bindable(''),
|
||||||
initialTitle = '',
|
initialTitle = '',
|
||||||
initialDocumentLocation = '',
|
initialDocumentLocation = '',
|
||||||
initialSummary = '',
|
initialSummary = '',
|
||||||
titleRequired = false,
|
titleRequired = false,
|
||||||
suggestedTitle = '',
|
suggestedTitle = '',
|
||||||
hideTitle = false
|
hideTitle = false,
|
||||||
|
editMode = false
|
||||||
}: {
|
}: {
|
||||||
tags?: Tag[];
|
tags?: Tag[];
|
||||||
currentTitle?: string;
|
currentTitle?: string;
|
||||||
|
documentLocation?: string;
|
||||||
|
archiveBox?: string;
|
||||||
|
archiveFolder?: string;
|
||||||
initialTitle?: string;
|
initialTitle?: string;
|
||||||
initialDocumentLocation?: string;
|
initialDocumentLocation?: string;
|
||||||
initialSummary?: string;
|
initialSummary?: string;
|
||||||
titleRequired?: boolean;
|
titleRequired?: boolean;
|
||||||
suggestedTitle?: string;
|
suggestedTitle?: string;
|
||||||
hideTitle?: boolean;
|
hideTitle?: boolean;
|
||||||
|
editMode?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let titleDirty = $state(false);
|
let titleDirty = $state(false);
|
||||||
currentTitle = untrack(() => initialTitle);
|
currentTitle = untrack(() => initialTitle);
|
||||||
const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || currentTitle);
|
const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || currentTitle);
|
||||||
|
|
||||||
|
// Initialize controlled location field once from the legacy initial-* props so
|
||||||
|
// callers that haven't switched to the bindable form keep their existing
|
||||||
|
// pre-fill behaviour.
|
||||||
|
documentLocation = untrack(() => documentLocation || initialDocumentLocation);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
@@ -67,40 +81,78 @@ const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || curren
|
|||||||
|
|
||||||
<!-- Schlagworte (optional) -->
|
<!-- Schlagworte (optional) -->
|
||||||
<div>
|
<div>
|
||||||
<p class="mb-1 block text-sm font-medium text-ink-2">{m.form_label_tags()}</p>
|
<p class="mb-1 block text-sm font-medium text-ink-2">
|
||||||
|
{m.form_label_tags()}
|
||||||
|
{#if editMode}<FieldLabelBadge variant="additive" />{/if}
|
||||||
|
</p>
|
||||||
<TagInput bind:tags={tags} />
|
<TagInput bind:tags={tags} />
|
||||||
<input type="hidden" name="tags" value={tags.map((t) => t.name).join(',')} />
|
<input type="hidden" name="tags" value={tags.map((t) => t.name).join(',')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Inhalt (optional) -->
|
{#if !editMode}
|
||||||
<div>
|
<!-- Inhalt (optional) — not bulk-editable. -->
|
||||||
<label for="summary" class="mb-1 block text-sm font-medium text-ink-2"
|
<div>
|
||||||
>{m.form_label_content()}</label
|
<label for="summary" class="mb-1 block text-sm font-medium text-ink-2"
|
||||||
>
|
>{m.form_label_content()}</label
|
||||||
<textarea
|
>
|
||||||
id="summary"
|
<textarea
|
||||||
name="summary"
|
id="summary"
|
||||||
rows="5"
|
name="summary"
|
||||||
placeholder={m.form_placeholder_content()}
|
rows="5"
|
||||||
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
placeholder={m.form_placeholder_content()}
|
||||||
>{initialSummary}</textarea
|
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
>
|
>{initialSummary}</textarea
|
||||||
</div>
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Aufbewahrungsort (optional) -->
|
<!-- Aufbewahrungsort (optional) -->
|
||||||
<div>
|
<div data-testid="description-document-location">
|
||||||
<label for="documentLocation" class="mb-1 block text-sm font-medium text-ink-2"
|
<label for="documentLocation" class="mb-1 block text-sm font-medium text-ink-2"
|
||||||
>{m.form_label_archive_location()}</label
|
>{m.form_label_archive_location()}
|
||||||
>
|
{#if editMode}<FieldLabelBadge variant="replace" />{/if}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
id="documentLocation"
|
id="documentLocation"
|
||||||
type="text"
|
type="text"
|
||||||
name="documentLocation"
|
name="documentLocation"
|
||||||
value={initialDocumentLocation}
|
bind:value={documentLocation}
|
||||||
placeholder={m.form_placeholder_archive_location()}
|
placeholder={m.form_placeholder_archive_location()}
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_location()}</p>
|
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_location()}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if editMode}
|
||||||
|
<!-- Karton (only in editMode — bulk-editable replace) -->
|
||||||
|
<div data-testid="description-archive-box">
|
||||||
|
<label for="archiveBox" class="mb-1 block text-sm font-medium text-ink-2"
|
||||||
|
>Karton
|
||||||
|
<FieldLabelBadge variant="replace" />
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="archiveBox"
|
||||||
|
type="text"
|
||||||
|
name="archiveBox"
|
||||||
|
bind:value={archiveBox}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mappe (only in editMode — bulk-editable replace) -->
|
||||||
|
<div data-testid="description-archive-folder">
|
||||||
|
<label for="archiveFolder" class="mb-1 block text-sm font-medium text-ink-2"
|
||||||
|
>Mappe
|
||||||
|
<FieldLabelBadge variant="replace" />
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="archiveFolder"
|
||||||
|
type="text"
|
||||||
|
name="archiveFolder"
|
||||||
|
bind:value={archiveFolder}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
16
frontend/src/lib/components/document/FieldLabelBadge.svelte
Normal file
16
frontend/src/lib/components/document/FieldLabelBadge.svelte
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
let { variant }: { variant: 'additive' | 'replace' } = $props();
|
||||||
|
|
||||||
|
const text = $derived(
|
||||||
|
variant === 'additive' ? m.bulk_edit_badge_additive() : m.bulk_edit_badge_replace()
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
data-testid="field-label-badge-{variant}"
|
||||||
|
class="ml-2 inline-flex items-center rounded-sm bg-muted px-1.5 py-0.5 text-[10px] font-medium tracking-wide text-gray-600"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,11 @@ import { m } from '$lib/paraglide/messages.js';
|
|||||||
|
|
||||||
export interface FileEntry {
|
export interface FileEntry {
|
||||||
id: string;
|
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;
|
title: string;
|
||||||
status: 'idle' | 'error';
|
status: 'idle' | 'error';
|
||||||
previewUrl: string;
|
previewUrl: string;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||||
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
|
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
|
||||||
|
import FieldLabelBadge from './FieldLabelBadge.svelte';
|
||||||
import { isoToGerman, handleGermanDateInput } from '$lib/utils/date';
|
import { isoToGerman, handleGermanDateInput } from '$lib/utils/date';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
@@ -16,7 +17,9 @@ let {
|
|||||||
initialLocation = '',
|
initialLocation = '',
|
||||||
initialSenderName = '',
|
initialSenderName = '',
|
||||||
suggestedDateIso = '',
|
suggestedDateIso = '',
|
||||||
suggestedSenderName = ''
|
suggestedSenderName = '',
|
||||||
|
hideDate = false,
|
||||||
|
editMode = false
|
||||||
}: {
|
}: {
|
||||||
senderId?: string;
|
senderId?: string;
|
||||||
selectedReceivers?: Person[];
|
selectedReceivers?: Person[];
|
||||||
@@ -26,6 +29,8 @@ let {
|
|||||||
initialSenderName?: string;
|
initialSenderName?: string;
|
||||||
suggestedDateIso?: string;
|
suggestedDateIso?: string;
|
||||||
suggestedSenderName?: string;
|
suggestedSenderName?: string;
|
||||||
|
hideDate?: boolean;
|
||||||
|
editMode?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let dateDisplay = $state(untrack(() => isoToGerman(initialDateIso)));
|
let dateDisplay = $state(untrack(() => isoToGerman(initialDateIso)));
|
||||||
@@ -56,60 +61,72 @@ $effect(() => {
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||||
<!-- Datum (required — row 1, col 1) -->
|
{#if !hideDate}
|
||||||
<div>
|
<!-- Datum (required — row 1, col 1) -->
|
||||||
<label for="documentDate" class="mb-1 block text-sm font-medium text-ink-2"
|
<div data-testid="who-when-date">
|
||||||
>{m.form_label_date()}*</label
|
<label for="documentDate" class="mb-1 block text-sm font-medium text-ink-2"
|
||||||
>
|
>{m.form_label_date()}*</label
|
||||||
<input
|
>
|
||||||
id="documentDate"
|
<input
|
||||||
type="text"
|
id="documentDate"
|
||||||
inputmode="numeric"
|
type="text"
|
||||||
value={dateDisplay}
|
inputmode="numeric"
|
||||||
oninput={handleDateInput}
|
value={dateDisplay}
|
||||||
placeholder={m.form_placeholder_date()}
|
oninput={handleDateInput}
|
||||||
maxlength="10"
|
placeholder={m.form_placeholder_date()}
|
||||||
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm
|
maxlength="10"
|
||||||
{dateInvalid ? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500' : 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
|
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm
|
||||||
aria-describedby={dateInvalid ? 'date-error' : undefined}
|
{dateInvalid
|
||||||
/>
|
? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500'
|
||||||
<input type="hidden" name="documentDate" value={dateIso} />
|
: 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
|
||||||
{#if dateInvalid}
|
aria-describedby={dateInvalid ? 'date-error' : undefined}
|
||||||
<p id="date-error" class="mt-1 text-xs text-red-600">{m.form_date_error()}</p>
|
/>
|
||||||
{/if}
|
<input type="hidden" name="documentDate" value={dateIso} />
|
||||||
</div>
|
{#if dateInvalid}
|
||||||
|
<p id="date-error" class="mt-1 text-xs text-red-600">{m.form_date_error()}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Absender (required — row 1, col 2) -->
|
<!-- Absender (required in upload mode — row 1, col 2) -->
|
||||||
<div>
|
<div>
|
||||||
<PersonTypeahead
|
<PersonTypeahead
|
||||||
name="senderId"
|
name="senderId"
|
||||||
label={m.form_label_sender()}
|
label={m.form_label_sender()}
|
||||||
required={true}
|
required={!editMode}
|
||||||
bind:value={senderId}
|
bind:value={senderId}
|
||||||
initialName={initialSenderName}
|
initialName={initialSenderName}
|
||||||
suggestedName={suggestedSenderName}
|
suggestedName={suggestedSenderName}
|
||||||
|
badge={editMode ? 'replace' : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empfänger (optional — row 2, col 1) -->
|
<!-- Empfänger (optional — row 2, col 1) -->
|
||||||
<div>
|
<div>
|
||||||
<p class="mb-1 block text-sm font-medium text-ink-2">{m.form_label_receivers()}</p>
|
<p class="mb-1 block text-sm font-medium text-ink-2">
|
||||||
|
{m.form_label_receivers()}
|
||||||
|
{#if editMode}<FieldLabelBadge variant="additive" />{/if}
|
||||||
|
</p>
|
||||||
<PersonMultiSelect bind:selectedPersons={selectedReceivers} />
|
<PersonMultiSelect bind:selectedPersons={selectedReceivers} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ort (optional — row 2, col 2) -->
|
{#if !editMode}
|
||||||
<div>
|
<!-- Ort (optional — row 2, col 2). Hidden in editMode: meta_location is
|
||||||
<label for="location" class="mb-1 block text-sm font-medium text-ink-2"
|
NOT bulk-editable per the issue spec; the three editable location
|
||||||
>{m.form_label_location()}</label
|
fields live in DescriptionSection. -->
|
||||||
>
|
<div>
|
||||||
<input
|
<label for="location" class="mb-1 block text-sm font-medium text-ink-2"
|
||||||
id="location"
|
>{m.form_label_location()}</label
|
||||||
type="text"
|
>
|
||||||
name="location"
|
<input
|
||||||
value={initialLocation}
|
id="location"
|
||||||
placeholder={m.form_placeholder_location()}
|
type="text"
|
||||||
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
name="location"
|
||||||
/>
|
value={initialLocation}
|
||||||
</div>
|
placeholder={m.form_placeholder_location()}
|
||||||
|
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user