feat(admin): OCR admin pages — overview & model detail #265

Merged
marcel merged 53 commits from feat/issue-264-ocr-admin-pages into main 2026-04-18 12:38:42 +02:00
11 changed files with 100 additions and 21 deletions
Showing only changes of commit a00617194c - Show all commits

View File

@@ -300,6 +300,22 @@
"history_field_tags": "Schlagworte",
"admin_tab_system": "System",
"admin_tab_ocr": "OCR",
"ocr_status_online": "Online",
"ocr_status_offline": "Offline",
"ocr_stat_training_blocks": "Trainingsblöcke",
"ocr_stat_total_blocks": "Gesamt Blöcke",
"ocr_stat_documents": "Dokumente",
"ocr_stat_seg_blocks": "Seg.-Blöcke",
"ocr_table_person": "Person",
"ocr_table_cer": "ZFR",
"ocr_table_accuracy": "Genauigkeit",
"ocr_table_lines": "Zeilen",
"ocr_table_actions": "Aktionen",
"ocr_table_details": "Details",
"ocr_no_models": "Noch keine Sender-Modelle trainiert.",
"ocr_sender_models_heading": "Sender-Modelle",
"ocr_global_history_link": "Globaler Verlauf →",
"ocr_global_history_heading": "Globaler Verlauf",
"admin_system_backfill_heading": "Verlaufsdaten auffüllen",
"admin_system_backfill_description": "Erstellt einen initialen Verlaufseintrag für alle Dokumente, die noch keinen Verlauf haben (z.B. importierte Dokumente). Dadurch werden beim nächsten Bearbeiten nur die tatsächlich geänderten Felder hervorgehoben.",
"admin_system_backfill_btn": "Jetzt auffüllen",

View File

@@ -300,6 +300,22 @@
"history_field_tags": "Tags",
"admin_tab_system": "System",
"admin_tab_ocr": "OCR",
"ocr_status_online": "Online",
"ocr_status_offline": "Offline",
"ocr_stat_training_blocks": "Training blocks",
"ocr_stat_total_blocks": "Total blocks",
"ocr_stat_documents": "Documents",
"ocr_stat_seg_blocks": "Seg. blocks",
"ocr_table_person": "Person",
"ocr_table_cer": "CER",
"ocr_table_accuracy": "Accuracy",
"ocr_table_lines": "Lines",
"ocr_table_actions": "Actions",
"ocr_table_details": "Details",
"ocr_no_models": "No sender models trained yet.",
"ocr_sender_models_heading": "Sender Models",
"ocr_global_history_link": "Global history →",
"ocr_global_history_heading": "Global History",
"admin_system_backfill_heading": "Backfill history data",
"admin_system_backfill_description": "Creates an initial history entry for all documents that do not have one yet (e.g. imported documents). This ensures that future edits only highlight actually changed fields.",
"admin_system_backfill_btn": "Backfill now",

View File

@@ -300,6 +300,22 @@
"history_field_tags": "Etiquetas",
"admin_tab_system": "Sistema",
"admin_tab_ocr": "OCR",
"ocr_status_online": "En línea",
"ocr_status_offline": "Sin conexión",
"ocr_stat_training_blocks": "Bloques de entrenamiento",
"ocr_stat_total_blocks": "Total de bloques",
"ocr_stat_documents": "Documentos",
"ocr_stat_seg_blocks": "Bloques de seg.",
"ocr_table_person": "Persona",
"ocr_table_cer": "TCE",
"ocr_table_accuracy": "Precisión",
"ocr_table_lines": "Líneas",
"ocr_table_actions": "Acciones",
"ocr_table_details": "Detalles",
"ocr_no_models": "Aún no hay modelos de remitente entrenados.",
"ocr_sender_models_heading": "Modelos de remitente",
"ocr_global_history_link": "Historial global →",
"ocr_global_history_heading": "Historial global",
"admin_system_backfill_heading": "Completar datos de historial",
"admin_system_backfill_description": "Crea una entrada de historial inicial para todos los documentos que aún no tienen ninguna (p.ej. documentos importados). Así, en la próxima edición solo se resaltarán los campos realmente modificados.",
"admin_system_backfill_btn": "Completar ahora",

View File

@@ -3,6 +3,7 @@ import type { PageData } from './$types';
import OcrHealthBar from './OcrHealthBar.svelte';
import OcrStatCards from './OcrStatCards.svelte';
import OcrModelsTable from './OcrModelsTable.svelte';
import * as m from '$lib/paraglide/messages.js';
let { data }: { data: PageData } = $props();
const { trainingInfo } = $derived(data);
@@ -11,7 +12,9 @@ const { trainingInfo } = $derived(data);
<div class="flex flex-col gap-6 p-6">
<!-- Page title + health bar -->
<div class="flex items-center justify-between">
<h1 class="font-sans text-lg font-bold tracking-widest text-brand-navy uppercase">OCR</h1>
<h1 class="font-sans text-lg font-bold tracking-widest text-brand-navy uppercase">
{m.admin_tab_ocr()}
</h1>
<OcrHealthBar ocrServiceAvailable={trainingInfo.ocrServiceAvailable ?? false} />
</div>
@@ -26,12 +29,14 @@ const { trainingInfo } = $derived(data);
<!-- Sender models -->
<div>
<div class="mb-3 flex items-center justify-between">
<h2 class="text-xs font-bold tracking-widest text-gray-400 uppercase">Sender Models</h2>
<h2 class="text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.ocr_sender_models_heading()}
</h2>
<a
href="/admin/ocr/global"
class="text-xs font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
>
Global history
{m.ocr_global_history_link()}
</a>
</div>
<OcrModelsTable

View File

@@ -1,14 +1,17 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
let { ocrServiceAvailable }: { ocrServiceAvailable: boolean } = $props();
</script>
<div class="flex items-center gap-2">
<span
role="img"
aria-label={ocrServiceAvailable ? m.ocr_status_online() : m.ocr_status_offline()}
class="inline-block h-2.5 w-2.5 rounded-full {ocrServiceAvailable
? 'bg-green-500'
: 'bg-red-500'}"
></span>
<span class="text-sm font-medium {ocrServiceAvailable ? 'text-green-700' : 'text-red-700'}">
{ocrServiceAvailable ? 'Online' : 'Offline'}
{ocrServiceAvailable ? m.ocr_status_online() : m.ocr_status_offline()}
</span>
</div>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
import * as m from '$lib/paraglide/messages.js';
type SenderModel = components['schemas']['SenderModel'];
let {
@@ -17,23 +18,23 @@ let {
<tr>
<th
class="border-brand-sand border-b pb-3 text-left text-xs font-bold tracking-widest text-gray-400 uppercase"
>Person</th
>{m.ocr_table_person()}</th
>
<th
class="border-brand-sand border-b pb-3 text-left text-xs font-bold tracking-widest text-gray-400 uppercase"
>CER</th
>{m.ocr_table_cer()}</th
>
<th
class="border-brand-sand border-b pb-3 text-left text-xs font-bold tracking-widest text-gray-400 uppercase"
>Accuracy</th
>{m.ocr_table_accuracy()}</th
>
<th
class="border-brand-sand border-b pb-3 text-left text-xs font-bold tracking-widest text-gray-400 uppercase"
>Lines</th
>{m.ocr_table_lines()}</th
>
<th
class="border-brand-sand border-b pb-3 text-left text-xs font-bold tracking-widest text-gray-400 uppercase"
>Actions</th
>{m.ocr_table_actions()}</th
>
</tr>
</thead>
@@ -57,10 +58,16 @@ let {
<td class="border-brand-sand/50 border-b py-3">
<a
href="/admin/ocr/{model.personId}"
class="font-medium text-brand-navy hover:underline">Details</a
class="font-medium text-brand-navy hover:underline">{m.ocr_table_details()}</a
>
</td>
</tr>
{:else}
<tr>
<td colspan="5" class="py-6 text-center text-sm text-gray-400">
{m.ocr_no_models()}
</td>
</tr>
{/each}
</tbody>
</table>

View File

@@ -36,9 +36,10 @@ describe('OcrModelsTable', () => {
await expect.element(page.getByText(personId)).toBeInTheDocument();
});
it('shows empty state when no models', async () => {
it('shows empty state row when no models', async () => {
render(OcrModelsTable, { senderModels: [], personNames: {} });
const rows = document.querySelectorAll('tbody tr');
expect(rows.length).toBe(0);
expect(rows.length).toBe(1);
expect(rows[0].querySelector('td[colspan="5"]')).not.toBeNull();
});
});

View File

@@ -1,4 +1,6 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
interface Props {
availableBlocks?: number;
totalOcrBlocks?: number;
@@ -18,19 +20,25 @@ let {
<div class="border-brand-sand rounded-sm border bg-white p-6 shadow-sm">
<div class="text-3xl font-bold text-brand-navy">{availableBlocks}</div>
<div class="mt-2 text-xs font-bold tracking-widest text-gray-400 uppercase">
Training blocks
{m.ocr_stat_training_blocks()}
</div>
</div>
<div class="border-brand-sand rounded-sm border bg-white p-6 shadow-sm">
<div class="text-3xl font-bold text-brand-navy">{totalOcrBlocks}</div>
<div class="mt-2 text-xs font-bold tracking-widest text-gray-400 uppercase">Total blocks</div>
<div class="mt-2 text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.ocr_stat_total_blocks()}
</div>
</div>
<div class="border-brand-sand rounded-sm border bg-white p-6 shadow-sm">
<div class="text-3xl font-bold text-brand-navy">{availableDocuments}</div>
<div class="mt-2 text-xs font-bold tracking-widest text-gray-400 uppercase">Documents</div>
<div class="mt-2 text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.ocr_stat_documents()}
</div>
</div>
<div class="border-brand-sand rounded-sm border bg-white p-6 shadow-sm">
<div class="text-3xl font-bold text-brand-navy">{availableSegBlocks}</div>
<div class="mt-2 text-xs font-bold tracking-widest text-gray-400 uppercase">Seg. blocks</div>
<div class="mt-2 text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.ocr_stat_seg_blocks()}
</div>
</div>
</div>

View File

@@ -27,4 +27,9 @@ describe('OcrStatCards', () => {
render(OcrStatCards, stats);
await expect.element(page.getByText('15')).toBeInTheDocument();
});
it('shows segmentation block count', async () => {
render(OcrStatCards, stats);
await expect.element(page.getByText('8')).toBeInTheDocument();
});
});

View File

@@ -1,9 +1,10 @@
<script lang="ts">
import type { PageData } from './$types';
import TrainingHistory from '$lib/components/TrainingHistory.svelte';
import * as m from '$lib/paraglide/messages.js';
let { data }: { data: PageData } = $props();
const personName = $derived(Object.values(data.history.personNames ?? {})[0] ?? 'Unknown');
let { data, params }: { data: PageData; params: { personId: string } } = $props();
const personName = $derived(data.history.personNames?.[params.personId] ?? 'Unknown');
</script>
<div class="flex flex-col gap-6 p-6">
@@ -20,7 +21,7 @@ const personName = $derived(Object.values(data.history.personNames ?? {})[0] ??
stroke-width="2"
><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" /></svg
>
OCR
{m.admin_tab_ocr()}
</a>
<h1 class="font-sans text-lg font-bold tracking-widest text-brand-navy uppercase">
{personName}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import type { PageData } from './$types';
import TrainingHistory from '$lib/components/TrainingHistory.svelte';
import * as m from '$lib/paraglide/messages.js';
let { data }: { data: PageData } = $props();
</script>
@@ -19,10 +20,10 @@ let { data }: { data: PageData } = $props();
stroke-width="2"
><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" /></svg
>
OCR
{m.admin_tab_ocr()}
</a>
<h1 class="font-sans text-lg font-bold tracking-widest text-brand-navy uppercase">
Global History
{m.ocr_global_history_heading()}
</h1>
</div>