fix(admin): i18n all hardcoded OCR strings, fix personName lookup, add empty state
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m17s
CI / OCR Service Tests (push) Successful in 57s
CI / Backend Unit Tests (push) Failing after 2m52s
CI / Unit & Component Tests (pull_request) Failing after 2m47s
CI / OCR Service Tests (pull_request) Successful in 43s
CI / Backend Unit Tests (pull_request) Failing after 2m48s

- Replace hardcoded EN strings in OcrHealthBar/OcrStatCards/OcrModelsTable with
  Paraglide message keys (de/en/es translations added)
- Add role=img + aria-label to OcrHealthBar status dot
- Add {:else} empty-state row in OcrModelsTable
- Fix personName derivation in [personId]/+page.svelte to use params.personId key
  instead of Object.values()[0] (fragile when multiple persons present)
- Update OcrModelsTable spec to assert empty-state row structure (locale-agnostic)
- Add missing availableSegBlocks test to OcrStatCards spec

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-18 08:59:49 +02:00
parent b879d28761
commit a00617194c
11 changed files with 100 additions and 21 deletions

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>