feat(enrich): add metadata enrichment queue UI
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 2m19s
CI / Backend Unit Tests (pull_request) Successful in 2m11s
CI / E2E Tests (pull_request) Failing after 29m32s
CI / Unit & Component Tests (push) Successful in 2m21s
CI / Backend Unit Tests (push) Successful in 2m12s
CI / E2E Tests (push) Failing after 28m54s

Home page shows "Needs metadata" card when incomplete documents exist.
/enrich list shows all incomplete documents; /enrich/[id] provides a
split PDF-preview + compact form view with Skip / Save / Save & reviewed
actions that auto-advance through the queue.

New document page gets Save vs Save & reviewed split. Edit page gets
"Mark for review" secondary button to push a document back into the queue.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit was merged in pull request #77.
This commit is contained in:
Marcel
2026-03-26 13:45:16 +01:00
parent 0ce18e1eed
commit aab9e9a4b0
16 changed files with 762 additions and 27 deletions

View File

@@ -274,5 +274,21 @@
"upload_duplicate": "{filename} existiert bereits —",
"upload_duplicate_link": "Zum Dokument",
"upload_invalid_type": "{filename}: Dateiformat nicht unterstützt",
"upload_error": "Fehler beim Hochladen von {filename}"
"upload_error": "Fehler beim Hochladen von {filename}",
"enrich_list_back": "Zurück zur Übersicht",
"enrich_list_count": "Dokumente",
"btn_save_and_mark_reviewed": "Speichern & abschließen",
"btn_mark_for_review": "Zur Überprüfung markieren",
"enrich_needs_metadata_title": "Dokumente ohne Metadaten",
"enrich_needs_metadata_count": "{count} Dokument(e) warten auf Metadaten",
"enrich_needs_metadata_cta": "Jetzt vervollständigen",
"enrich_list_heading": "Dokumente ohne Metadaten",
"enrich_list_empty_heading": "Alle Dokumente vollständig",
"enrich_list_empty_body": "Es gibt keine Dokumente, die noch Metadaten benötigen.",
"enrich_list_start": "Überprüfung starten",
"enrich_progress": "{count} verbleibend",
"enrich_skip": "Überspringen",
"enrich_done_heading": "Alles erledigt!",
"enrich_done_body": "Alle Dokumente wurden bearbeitet.",
"enrich_back_to_list": "Zurück zur Liste"
}

View File

@@ -274,5 +274,21 @@
"upload_duplicate": "{filename} already exists —",
"upload_duplicate_link": "View document",
"upload_invalid_type": "{filename}: unsupported file format",
"upload_error": "Error uploading {filename}"
"upload_error": "Error uploading {filename}",
"enrich_list_back": "Back to overview",
"enrich_list_count": "documents",
"btn_save_and_mark_reviewed": "Save & mark as reviewed",
"btn_mark_for_review": "Mark for review",
"enrich_needs_metadata_title": "Documents without metadata",
"enrich_needs_metadata_count": "{count} document(s) waiting for metadata",
"enrich_needs_metadata_cta": "Complete now",
"enrich_list_heading": "Documents without metadata",
"enrich_list_empty_heading": "All documents complete",
"enrich_list_empty_body": "There are no documents that still need metadata.",
"enrich_list_start": "Start reviewing",
"enrich_progress": "{count} remaining",
"enrich_skip": "Skip",
"enrich_done_heading": "All done!",
"enrich_done_body": "All documents have been processed.",
"enrich_back_to_list": "Back to list"
}

View File

@@ -274,5 +274,21 @@
"upload_duplicate": "{filename} ya existe —",
"upload_duplicate_link": "Ver documento",
"upload_invalid_type": "{filename}: formato de archivo no admitido",
"upload_error": "Error al subir {filename}"
"upload_error": "Error al subir {filename}",
"enrich_list_back": "Volver a la vista general",
"enrich_list_count": "documentos",
"btn_save_and_mark_reviewed": "Guardar y marcar como revisado",
"btn_mark_for_review": "Marcar para revisión",
"enrich_needs_metadata_title": "Documentos sin metadatos",
"enrich_needs_metadata_count": "{count} documento(s) esperando metadatos",
"enrich_needs_metadata_cta": "Completar ahora",
"enrich_list_heading": "Documentos sin metadatos",
"enrich_list_empty_heading": "Todos los documentos completos",
"enrich_list_empty_body": "No hay documentos que necesiten metadatos.",
"enrich_list_start": "Comenzar revisión",
"enrich_progress": "{count} restante(s)",
"enrich_skip": "Omitir",
"enrich_done_heading": "¡Todo listo!",
"enrich_done_body": "Todos los documentos han sido procesados.",
"enrich_back_to_list": "Volver a la lista"
}

View File

@@ -468,6 +468,54 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/documents/incomplete-count": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getIncompleteCount"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/incomplete": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getIncomplete"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/incomplete/next": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getNextIncomplete"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/search": {
parameters: {
query?: never;
@@ -1819,6 +1867,77 @@ export interface operations {
};
};
};
getIncompleteCount: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": {
count: number;
};
};
};
};
};
getIncomplete: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Document"][];
};
};
};
};
getNextIncomplete: {
parameters: {
query: {
excludeId: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Document"];
};
};
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
importStatus: {
parameters: {
query?: never;

View File

@@ -12,7 +12,7 @@ export async function load({ url, fetch }) {
const api = createApiClient(fetch);
try {
const [docsResult, personsResult] = await Promise.all([
const [docsResult, personsResult, incompleteCountResult] = await Promise.all([
api.GET('/api/documents/search', {
params: {
query: {
@@ -25,7 +25,8 @@ export async function load({ url, fetch }) {
}
}
}),
api.GET('/api/persons')
api.GET('/api/persons'),
api.GET('/api/documents/incomplete-count')
]);
if (docsResult.response.status === 401 || personsResult.response.status === 401) {
@@ -39,8 +40,13 @@ export async function load({ url, fetch }) {
const senderObj = allPersons.find((p) => p.id === senderId);
const receiverObj = allPersons.find((p) => p.id === receiverId);
const incompleteCount = incompleteCountResult.response.ok
? (incompleteCountResult.data?.count ?? 0)
: 0;
return {
documents,
incompleteCount,
initialValues: {
senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}` : '',
receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}` : ''
@@ -53,6 +59,7 @@ export async function load({ url, fetch }) {
console.error('Error loading data:', e);
return {
documents: [],
incompleteCount: 0,
initialValues: { senderName: '', receiverName: '' },
filters: { q, from, to, senderId, receiverId, tags },
error: 'Daten konnten nicht geladen werden.' as string | null

View File

@@ -5,6 +5,7 @@ import { SvelteURLSearchParams } from 'svelte/reactivity';
import SearchFilterBar from './SearchFilterBar.svelte';
import DropZone from './DropZone.svelte';
import DocumentList from './DocumentList.svelte';
import { m } from '$lib/paraglide/messages.js';
let { data } = $props();
@@ -86,5 +87,34 @@ $effect(() => {
<DropZone />
{/if}
{#if data.incompleteCount > 0}
<a
href="/enrich"
class="mb-6 flex items-center justify-between rounded-sm border border-brand-mint/40 bg-brand-mint/10 px-6 py-4 transition-colors hover:bg-brand-mint/20"
>
<div class="flex items-center gap-4">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Info/Block/Info-Block-Border-MD.svg"
alt=""
aria-hidden="true"
class="h-6 w-6 opacity-60"
/>
<div>
<p class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase">
{m.enrich_needs_metadata_title()}
</p>
<p class="mt-0.5 font-serif text-sm text-brand-navy/70">
{m.enrich_needs_metadata_count({ count: data.incompleteCount })}
</p>
</div>
</div>
<span
class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase transition-colors hover:text-brand-navy/70"
>
{m.enrich_needs_metadata_cta()}
</span>
</a>
{/if}
<DocumentList documents={data.documents ?? []} canWrite={data.canWrite} error={data.error} />
</main>

View File

@@ -60,6 +60,53 @@ export const actions = {
throw redirect(303, `/documents/${params.id}`);
},
markForReview: async ({
params,
fetch
}: {
params: { id: string };
fetch: typeof globalThis.fetch;
}) => {
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const api = createApiClient(fetch);
// Fetch current document to preserve all existing fields
const docResult = await api.GET('/api/documents/{id}', { params: { path: { id: params.id } } });
if (!docResult.response.ok) {
const code = (docResult.error as unknown as { code?: string })?.code;
return fail(docResult.response.status, { error: getErrorMessage(code) });
}
const doc = docResult.data!;
const formData = new FormData();
if (doc.title) formData.set('title', doc.title);
if (doc.documentDate) formData.set('documentDate', doc.documentDate);
if (doc.location) formData.set('location', doc.location);
if (doc.documentLocation) formData.set('documentLocation', doc.documentLocation);
if (doc.transcription) formData.set('transcription', doc.transcription);
if (doc.summary) formData.set('summary', doc.summary);
if (doc.sender?.id) formData.set('senderId', doc.sender.id);
if (doc.receivers?.length) {
doc.receivers.forEach((r: { id: string }) => formData.append('receiverIds', r.id));
}
if (doc.tags?.length) {
formData.set('tags', doc.tags.map((t: { name: string }) => t.name).join(','));
}
formData.set('metadataComplete', 'false');
const res = await fetch(`${baseUrl}/api/documents/${params.id}`, {
method: 'PUT',
body: formData
});
if (!res.ok) {
const backendError = await parseBackendError(res);
return fail(res.status, { error: getErrorMessage(backendError?.code) });
}
throw redirect(303, `/documents/${params.id}`);
},
delete: async ({ params, fetch }) => {
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { enhance } from '$app/forms';
let { docId }: { docId: string } = $props();
@@ -54,7 +55,7 @@ let confirmDelete = $state(false);
{/if}
</div>
<!-- Right: cancel + save -->
<!-- Right: cancel + mark for review + save -->
<div class="flex items-center gap-4">
<a
href="/documents/{docId}"
@@ -62,6 +63,13 @@ let confirmDelete = $state(false);
>
{m.btn_cancel()}
</a>
<button
type="submit"
form="mark-for-review-form"
class="rounded-sm border border-gray-300 px-4 py-2 font-sans text-xs font-bold tracking-widest text-gray-600 uppercase transition-colors hover:bg-gray-50"
>
{m.btn_mark_for_review()}
</button>
<button
type="submit"
class="rounded bg-primary px-6 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/80"
@@ -70,3 +78,5 @@ let confirmDelete = $state(false);
</button>
</div>
</div>
<form id="mark-for-review-form" method="POST" action="?/markForReview" use:enhance></form>

View File

@@ -55,22 +55,41 @@ export async function load({
};
}
async function submitNewDocument(
request: Request,
fetch: typeof globalThis.fetch,
metadataComplete: boolean
) {
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const formData = await request.formData();
formData.set('metadataComplete', String(metadataComplete));
const res = await fetch(`${baseUrl}/api/documents`, {
method: 'POST',
body: formData
});
if (!res.ok) {
const backendError = await parseBackendError(res);
return fail(res.status, { error: getErrorMessage(backendError?.code) });
}
const created = await res.json();
throw redirect(303, `/documents/${created.id}`);
}
export const actions = {
default: async ({ request, fetch }) => {
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const formData = await request.formData();
save: async ({ request, fetch }: { request: Request; fetch: typeof globalThis.fetch }) => {
return submitNewDocument(request, fetch, false);
},
const res = await fetch(`${baseUrl}/api/documents`, {
method: 'POST',
body: formData
});
if (!res.ok) {
const backendError = await parseBackendError(res);
return fail(res.status, { error: getErrorMessage(backendError?.code) });
}
const created = await res.json();
throw redirect(303, `/documents/${created.id}`);
saveReviewed: async ({
request,
fetch
}: {
request: Request;
fetch: typeof globalThis.fetch;
}) => {
return submitNewDocument(request, fetch, true);
}
};

View File

@@ -62,12 +62,26 @@ let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $
<a href="/" class="text-sm font-medium text-ink-2 transition-colors hover:text-ink">
{m.btn_cancel()}
</a>
<button
type="submit"
class="rounded bg-primary px-6 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/80"
>
{m.btn_save()}
</button>
<div class="flex items-center gap-3">
<button
type="submit"
name="metadataComplete"
value="false"
formaction="?/save"
class="rounded-sm border border-gray-300 px-5 py-2 font-sans text-xs font-bold tracking-widest text-gray-600 uppercase transition-colors hover:bg-gray-50"
>
{m.btn_save()}
</button>
<button
type="submit"
name="metadataComplete"
value="true"
formaction="?/saveReviewed"
class="rounded-sm bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90"
>
{m.btn_save_and_mark_reviewed()}
</button>
</div>
</div>
</form>
</div>

View File

@@ -0,0 +1,23 @@
import { redirect } from '@sveltejs/kit';
import { createApiClient } from '$lib/api.server';
export async function load({
fetch,
locals
}: {
fetch: typeof globalThis.fetch;
locals: App.Locals;
}) {
const canWrite =
locals.user?.groups?.some((g: { permissions: string[] }) =>
g.permissions.includes('WRITE_ALL')
) ?? false;
if (!canWrite) throw redirect(303, '/');
const api = createApiClient(fetch);
const result = await api.GET('/api/documents/incomplete');
const documents = result.response.ok ? (result.data ?? []) : [];
return { documents };
}

View File

@@ -0,0 +1,106 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let { data } = $props();
const documents = $derived(data.documents);
const count = $derived(documents.length);
function formatUploadDate(createdAt: string): string {
return new Intl.DateTimeFormat('de-DE', {
day: 'numeric',
month: 'long',
year: 'numeric'
}).format(new Date(createdAt));
}
</script>
<div class="mx-auto max-w-4xl px-4 py-10">
<!-- Back Link -->
<a
href="/"
class="group mb-4 inline-flex items-center font-sans text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
alt=""
aria-hidden="true"
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
/>
{m.enrich_list_back()}
</a>
<!-- Page Header -->
<div class="border-brand-sand mb-8 flex items-center justify-between border-b pb-6">
<div>
<h1 class="font-serif text-3xl font-medium text-brand-navy">
{m.enrich_list_heading()}
</h1>
{#if count > 0}
<p class="mt-2 font-sans text-sm text-brand-navy/60">
{count}
{m.enrich_list_count()}
</p>
{/if}
</div>
{#if count > 0}
<a
href="/enrich/{documents[0].id}"
class="bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90"
>
{m.enrich_list_start()}
</a>
{/if}
</div>
<!-- Empty State -->
{#if count === 0}
<div
class="border-brand-sand flex flex-col items-center justify-center rounded-sm border border-dashed bg-white py-20 text-center"
>
<div class="bg-brand-sand/60 mb-4 flex h-14 w-14 items-center justify-center rounded-full">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Check/Check-Circle-MD.svg"
alt=""
aria-hidden="true"
class="h-7 w-7 opacity-50"
/>
</div>
<p class="font-serif text-lg font-medium text-brand-navy">
{m.enrich_list_empty_heading()}
</p>
<p class="mt-2 max-w-xs font-sans text-sm text-brand-navy/60">
{m.enrich_list_empty_body()}
</p>
</div>
{:else}
<!-- Document Rows -->
<div class="border-brand-sand border bg-white shadow-sm">
<ul class="divide-brand-sand divide-y">
{#each documents as doc (doc.id)}
<li class="group hover:bg-brand-sand/30 transition-colors duration-200">
<a href="/enrich/{doc.id}" class="flex items-center justify-between p-6">
<div class="min-w-0 flex-1">
<p
class="font-serif text-lg font-medium text-brand-navy decoration-brand-mint decoration-2 underline-offset-4 group-hover:underline"
>
{doc.title || doc.originalFilename}
</p>
<p class="mt-1 font-sans text-xs text-brand-navy/50">
{formatUploadDate(doc.createdAt)}
</p>
</div>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg"
alt=""
aria-hidden="true"
class="ml-4 h-5 w-5 shrink-0 opacity-30 transition-opacity group-hover:opacity-70"
/>
</a>
</li>
{/each}
</ul>
</div>
{/if}
</div>

View File

@@ -0,0 +1,109 @@
import { error, redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage, parseBackendError } from '$lib/errors';
export async function load({
params,
fetch,
locals
}: {
params: { id: string };
fetch: typeof globalThis.fetch;
locals: App.Locals;
}) {
const canWrite =
locals.user?.groups?.some((g: { permissions: string[] }) =>
g.permissions.includes('WRITE_ALL')
) ?? false;
if (!canWrite) throw redirect(303, '/');
const { id } = params;
const api = createApiClient(fetch);
const [docResult, countResult] = await Promise.all([
api.GET('/api/documents/{id}', { params: { path: { id } } }),
api.GET('/api/documents/incomplete-count')
]);
if (!docResult.response.ok) {
const code = (docResult.error as unknown as { code?: string })?.code;
throw error(docResult.response.status, getErrorMessage(code));
}
const incompleteCount = countResult.response.ok ? (countResult.data?.count ?? 0) : 0;
return {
document: docResult.data!,
incompleteCount
};
}
async function redirectToNext(id: string, fetch: typeof globalThis.fetch): Promise<never> {
const api = createApiClient(fetch);
const nextResult = await api.GET('/api/documents/incomplete/next', {
params: { query: { excludeId: id } }
});
if (nextResult.response.ok && nextResult.data) {
throw redirect(303, `/enrich/${nextResult.data.id}`);
}
throw redirect(303, '/enrich/done');
}
export const actions = {
skip: async ({ params, fetch }: { params: { id: string }; fetch: typeof globalThis.fetch }) => {
await redirectToNext(params.id, fetch);
},
save: async ({
params,
request,
fetch
}: {
params: { id: string };
request: Request;
fetch: typeof globalThis.fetch;
}) => {
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const formData = await request.formData();
const res = await fetch(`${baseUrl}/api/documents/${params.id}`, {
method: 'PUT',
body: formData
});
if (!res.ok) {
const backendError = await parseBackendError(res);
return { error: getErrorMessage(backendError?.code) };
}
await redirectToNext(params.id, fetch);
},
saveAndReview: async ({
params,
request,
fetch
}: {
params: { id: string };
request: Request;
fetch: typeof globalThis.fetch;
}) => {
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const formData = await request.formData();
formData.set('metadataComplete', 'true');
const res = await fetch(`${baseUrl}/api/documents/${params.id}`, {
method: 'PUT',
body: formData
});
if (!res.ok) {
const backendError = await parseBackendError(res);
return { error: getErrorMessage(backendError?.code) };
}
await redirectToNext(params.id, fetch);
}
};

View File

@@ -0,0 +1,162 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte';
import DescriptionSection from '$lib/components/document/DescriptionSection.svelte';
let { data, form } = $props();
const doc = $derived(data.document);
// File preview state
let fileUrl = $state('');
let fileError = $state('');
let isLoading = $state(false);
// Dummy bindable state required by DocumentViewer
let annotateMode = $state(false);
let activeAnnotationId = $state<string | null>(null);
let activeAnnotationPage = $state<number | null>(null);
$effect(() => {
if (doc?.id && doc?.filePath) {
loadFile(doc.id);
}
});
async function loadFile(id: string) {
isLoading = true;
fileError = '';
fileUrl = '';
try {
const response = await fetch(`/api/documents/${id}/file`);
if (!response.ok) throw new Error('Fehler');
const blob = await response.blob();
fileUrl = URL.createObjectURL(blob);
} catch {
fileError = m.doc_file_error_preview();
} finally {
isLoading = false;
}
}
// Form state
let tags = $state(untrack(() => doc.tags?.map((t: { name: string }) => t.name) ?? []));
let senderId = $state(untrack(() => doc.sender?.id ?? ''));
let selectedReceivers = $state(untrack(() => doc.receivers ?? []));
</script>
<svelte:head>
<title>{doc.title || doc.originalFilename || 'Dokument'} — Anreicherung</title>
</svelte:head>
<div class="flex h-[calc(100vh-68px)] flex-col">
<!-- Top bar -->
<div class="flex items-center justify-between border-b border-line bg-surface px-6 py-3">
<a
href="/enrich"
class="group inline-flex items-center font-sans text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
alt=""
aria-hidden="true"
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
/>
{m.enrich_back_to_list()}
</a>
<p class="max-w-sm truncate text-center font-serif text-sm font-medium text-ink">
{doc.title || doc.originalFilename}
</p>
<p class="font-sans text-xs text-ink-3">
{m.enrich_progress({ count: data.incompleteCount })}
</p>
</div>
<!-- Main content -->
<div class="flex flex-1 overflow-hidden">
<!-- Left: PDF preview (60%) -->
<div class="relative flex-[6] overflow-hidden border-r border-line">
<DocumentViewer
doc={doc}
fileUrl={fileUrl}
isLoading={isLoading}
error={fileError}
bind:annotateMode={annotateMode}
bind:activeAnnotationId={activeAnnotationId}
bind:activeAnnotationPage={activeAnnotationPage}
onAnnotationClick={() => {}}
/>
</div>
<!-- Right: form (40%) -->
<div class="flex flex-[4] flex-col overflow-hidden">
{#if form?.error}
<div class="border-b border-red-200 bg-red-50 px-6 py-3 text-sm text-red-700">
{form.error}
</div>
{/if}
<form
id="save-form"
method="POST"
action="?/save"
enctype="multipart/form-data"
use:enhance
class="flex-1 space-y-5 overflow-y-auto p-6"
>
<WhoWhenSection
bind:senderId={senderId}
bind:selectedReceivers={selectedReceivers}
initialDateIso={doc.documentDate ?? ''}
initialLocation={doc.location ?? ''}
initialSenderName={doc.sender
? `${doc.sender.firstName} ${doc.sender.lastName}`
: ''}
/>
<DescriptionSection bind:tags={tags} initialTitle={doc.title ?? ''} titleRequired={true} />
</form>
<!-- Skip form (outside main form to avoid nesting) -->
<form id="skip-form" method="POST" action="?/skip" use:enhance></form>
<!-- Action bar -->
<div class="flex items-center justify-between gap-3 border-t border-line bg-surface p-4">
<!-- Skip button linked to skip-form -->
<button
type="submit"
form="skip-form"
class="font-sans text-sm font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
>
{m.enrich_skip()}
</button>
<div class="flex items-center gap-3">
<!-- Save -->
<button
type="submit"
form="save-form"
formaction="?/save"
class="rounded-sm border border-gray-300 px-5 py-2 font-sans text-xs font-bold tracking-widest text-gray-600 uppercase transition-colors hover:bg-gray-50"
>
{m.btn_save()}
</button>
<!-- Save & mark as reviewed -->
<button
type="submit"
form="save-form"
formaction="?/saveAndReview"
class="rounded-sm bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90"
>
{m.btn_save_and_mark_reviewed()}
</button>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
</script>
<div class="mx-auto max-w-4xl px-4 py-10">
<div
class="border-brand-sand flex flex-col items-center justify-center rounded-sm border bg-white py-20 text-center shadow-sm"
>
<img
src="/degruyter-icons/Simple/Large-32px/SVG/Action/Check/Check-Circle-LG.svg"
alt=""
aria-hidden="true"
class="mb-6 h-16 w-16"
/>
<h1 class="font-serif text-2xl font-medium text-brand-navy">
{m.enrich_done_heading()}
</h1>
<p class="mt-2 max-w-xs font-sans text-sm text-gray-500">
{m.enrich_done_body()}
</p>
<div class="mt-8 flex flex-col items-center gap-4">
<a
href="/"
class="bg-brand-navy px-6 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90"
>
{m.btn_back_to_overview()}
</a>
<a
href="/enrich"
class="font-sans text-xs text-gray-400 underline-offset-4 transition-colors hover:text-brand-navy hover:underline"
>
{m.enrich_back_to_list()}
</a>
</div>
</div>
</div>

View File

@@ -23,6 +23,7 @@ const emptyData = {
canAnnotate: false,
filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] },
documents: [],
incompleteCount: 0,
initialValues: { senderName: '', receiverName: '' },
error: null
};