From aab9e9a4b0122d39e49b4bc581864307d91a7086 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 26 Mar 2026 13:45:16 +0100 Subject: [PATCH] feat(enrich): add metadata enrichment queue UI 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 --- frontend/messages/de.json | 18 +- frontend/messages/en.json | 18 +- frontend/messages/es.json | 18 +- frontend/src/lib/generated/api.ts | 119 +++++++++++++ frontend/src/routes/+page.server.ts | 11 +- frontend/src/routes/+page.svelte | 30 ++++ .../documents/[id]/edit/+page.server.ts | 47 +++++ .../routes/documents/[id]/edit/SaveBar.svelte | 12 +- .../src/routes/documents/new/+page.server.ts | 49 ++++-- .../src/routes/documents/new/+page.svelte | 26 ++- frontend/src/routes/enrich/+page.server.ts | 23 +++ frontend/src/routes/enrich/+page.svelte | 106 ++++++++++++ .../src/routes/enrich/[id]/+page.server.ts | 109 ++++++++++++ frontend/src/routes/enrich/[id]/+page.svelte | 162 ++++++++++++++++++ frontend/src/routes/enrich/done/+page.svelte | 40 +++++ frontend/src/routes/page.svelte.spec.ts | 1 + 16 files changed, 762 insertions(+), 27 deletions(-) create mode 100644 frontend/src/routes/enrich/+page.server.ts create mode 100644 frontend/src/routes/enrich/+page.svelte create mode 100644 frontend/src/routes/enrich/[id]/+page.server.ts create mode 100644 frontend/src/routes/enrich/[id]/+page.svelte create mode 100644 frontend/src/routes/enrich/done/+page.svelte diff --git a/frontend/messages/de.json b/frontend/messages/de.json index c033a147..c441cbf8 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -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" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index f9d53545..abd1c189 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -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" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 958fb02e..0839bf4e 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -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" } diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 9659f8a1..c6fb5ac0 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -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; diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index b9ed284a..248fc783 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -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 diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index e5130ff1..57f40f8e 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -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(() => { {/if} + {#if data.incompleteCount > 0} + +
+ +
+

+ {m.enrich_needs_metadata_title()} +

+

+ {m.enrich_needs_metadata_count({ count: data.incompleteCount })} +

+
+
+ + {m.enrich_needs_metadata_cta()} → + +
+ {/if} + diff --git a/frontend/src/routes/documents/[id]/edit/+page.server.ts b/frontend/src/routes/documents/[id]/edit/+page.server.ts index eaaf86e3..de11c47a 100644 --- a/frontend/src/routes/documents/[id]/edit/+page.server.ts +++ b/frontend/src/routes/documents/[id]/edit/+page.server.ts @@ -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'; diff --git a/frontend/src/routes/documents/[id]/edit/SaveBar.svelte b/frontend/src/routes/documents/[id]/edit/SaveBar.svelte index 8fd6547b..4520eb76 100644 --- a/frontend/src/routes/documents/[id]/edit/SaveBar.svelte +++ b/frontend/src/routes/documents/[id]/edit/SaveBar.svelte @@ -1,5 +1,6 @@ + +
+ + + + {m.enrich_list_back()} + + + +
+
+

+ {m.enrich_list_heading()} +

+ {#if count > 0} +

+ {count} + {m.enrich_list_count()} +

+ {/if} +
+ + {#if count > 0} + + {m.enrich_list_start()} + + {/if} +
+ + + {#if count === 0} +
+
+ +
+

+ {m.enrich_list_empty_heading()} +

+

+ {m.enrich_list_empty_body()} +

+
+ {:else} + + + {/if} +
diff --git a/frontend/src/routes/enrich/[id]/+page.server.ts b/frontend/src/routes/enrich/[id]/+page.server.ts new file mode 100644 index 00000000..f3e6b95d --- /dev/null +++ b/frontend/src/routes/enrich/[id]/+page.server.ts @@ -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 { + 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); + } +}; diff --git a/frontend/src/routes/enrich/[id]/+page.svelte b/frontend/src/routes/enrich/[id]/+page.svelte new file mode 100644 index 00000000..5bd896e2 --- /dev/null +++ b/frontend/src/routes/enrich/[id]/+page.svelte @@ -0,0 +1,162 @@ + + + + {doc.title || doc.originalFilename || 'Dokument'} — Anreicherung + + +
+ +
+ + + {m.enrich_back_to_list()} + + +

+ {doc.title || doc.originalFilename} +

+ +

+ {m.enrich_progress({ count: data.incompleteCount })} +

+
+ + +
+ +
+ {}} + /> +
+ + +
+ {#if form?.error} +
+ {form.error} +
+ {/if} + +
+ + + + + +
+ + +
+ + + +
+ + + + + +
+
+
+
+
diff --git a/frontend/src/routes/enrich/done/+page.svelte b/frontend/src/routes/enrich/done/+page.svelte new file mode 100644 index 00000000..22000e9c --- /dev/null +++ b/frontend/src/routes/enrich/done/+page.svelte @@ -0,0 +1,40 @@ + + +
+
+ + +

+ {m.enrich_done_heading()} +

+ +

+ {m.enrich_done_body()} +

+ + +
+
diff --git a/frontend/src/routes/page.svelte.spec.ts b/frontend/src/routes/page.svelte.spec.ts index ec80ec0c..09956fc7 100644 --- a/frontend/src/routes/page.svelte.spec.ts +++ b/frontend/src/routes/page.svelte.spec.ts @@ -23,6 +23,7 @@ const emptyData = { canAnnotate: false, filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] }, documents: [], + incompleteCount: 0, initialValues: { senderName: '', receiverName: '' }, error: null };