feat(admin): add OCR admin routes — overview, global history, sender detail
Some checks failed
CI / Backend Unit Tests (push) Failing after 2m45s
CI / Unit & Component Tests (pull_request) Failing after 2m32s
CI / OCR Service Tests (pull_request) Successful in 27s
CI / Backend Unit Tests (pull_request) Failing after 2m44s
CI / Unit & Component Tests (push) Failing after 2m35s
CI / OCR Service Tests (push) Successful in 30s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-18 01:05:08 +02:00
parent 0d8ac46639
commit 8acb830649
9 changed files with 241 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
export const load: PageServerLoad = async ({ fetch }) => {
const api = createApiClient(fetch);
const result = await api.GET('/api/ocr/training-info');
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
return { trainingInfo: result.data! };
};

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import type { PageData } from './$types';
import OcrHealthBar from './OcrHealthBar.svelte';
import OcrStatCards from './OcrStatCards.svelte';
import OcrModelsTable from './OcrModelsTable.svelte';
let { data }: { data: PageData } = $props();
const { trainingInfo } = $derived(data);
</script>
<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>
<OcrHealthBar ocrServiceAvailable={trainingInfo.ocrServiceAvailable ?? false} />
</div>
<!-- Stats -->
<OcrStatCards
availableBlocks={trainingInfo.availableBlocks ?? 0}
totalOcrBlocks={trainingInfo.totalOcrBlocks ?? 0}
availableDocuments={trainingInfo.availableDocuments ?? 0}
availableSegBlocks={trainingInfo.availableSegBlocks ?? 0}
/>
<!-- 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>
<a
href="/admin/ocr/global"
class="text-xs font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
>
Global history →
</a>
</div>
<OcrModelsTable
senderModels={trainingInfo.senderModels ?? []}
personNames={trainingInfo.personNames ?? {}}
/>
</div>
</div>

View File

@@ -0,0 +1,18 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
export const load: PageServerLoad = async ({ params, fetch }) => {
const api = createApiClient(fetch);
const result = await api.GET('/api/ocr/training-info/{personId}', {
params: { path: { personId: params.personId } }
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
return { history: result.data! };
};

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import type { PageData } from './$types';
import TrainingHistory from '$lib/components/TrainingHistory.svelte';
let { data }: { data: PageData } = $props();
const personName = $derived(Object.values(data.history.personNames ?? {})[0] ?? 'Unknown');
</script>
<div class="flex flex-col gap-6 p-6">
<div class="flex items-center gap-4">
<a
href="/admin/ocr"
class="group inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy"
>
<svg
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" /></svg
>
OCR
</a>
<h1 class="font-sans text-lg font-bold tracking-widest text-brand-navy uppercase">
{personName}
</h1>
</div>
<TrainingHistory runs={data.history.runs ?? []} personNames={data.history.personNames ?? {}} />
</div>

View File

@@ -0,0 +1,33 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { load } from './+page.server';
const mockApi = { GET: vi.fn() };
vi.mock('$lib/api.server', () => ({ createApiClient: () => mockApi }));
beforeEach(() => vi.clearAllMocks());
describe('admin/ocr/[personId] — load', () => {
it('returns sender history from API', async () => {
const personId = '123e4567-e89b-12d3-a456-426614174000';
mockApi.GET.mockResolvedValue({
response: { ok: true },
data: { runs: [], personNames: { [personId]: 'Anna Müller' } }
});
const result = (await load({ params: { personId }, fetch } as never))!;
expect(result.history.personNames?.[personId]).toBe('Anna Müller');
});
it('throws 404 when person not found', async () => {
mockApi.GET.mockResolvedValue({
response: { ok: false, status: 404 },
error: { code: 'PERSON_NOT_FOUND' }
});
await expect(
load({ params: { personId: 'unknown-id' }, fetch } as never)
).rejects.toMatchObject({ status: 404 });
});
});

View File

@@ -0,0 +1,16 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
export const load: PageServerLoad = async ({ fetch }) => {
const api = createApiClient(fetch);
const result = await api.GET('/api/ocr/training-info/global');
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
return { history: result.data! };
};

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import type { PageData } from './$types';
import TrainingHistory from '$lib/components/TrainingHistory.svelte';
let { data }: { data: PageData } = $props();
</script>
<div class="flex flex-col gap-6 p-6">
<div class="flex items-center gap-4">
<a
href="/admin/ocr"
class="group inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy"
>
<svg
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" /></svg
>
OCR
</a>
<h1 class="font-sans text-lg font-bold tracking-widest text-brand-navy uppercase">
Global History
</h1>
</div>
<TrainingHistory runs={data.history.runs ?? []} personNames={data.history.personNames ?? {}} />
</div>

View File

@@ -0,0 +1,27 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { load } from './+page.server';
const mockApi = { GET: vi.fn() };
vi.mock('$lib/api.server', () => ({ createApiClient: () => mockApi }));
beforeEach(() => vi.clearAllMocks());
describe('admin/ocr/global — load', () => {
it('returns history from API', async () => {
mockApi.GET.mockResolvedValue({
response: { ok: true },
data: { runs: [{ id: 'run1' }], personNames: {} }
});
const result = (await load({ fetch } as never))!;
expect(result.history.runs).toHaveLength(1);
});
it('throws error when API call fails', async () => {
mockApi.GET.mockResolvedValue({ response: { ok: false, status: 500 }, error: {} });
await expect(load({ fetch } as never)).rejects.toMatchObject({ status: 500 });
});
});

View File

@@ -0,0 +1,28 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { load } from './+page.server';
const mockApi = { GET: vi.fn() };
vi.mock('$lib/api.server', () => ({ createApiClient: () => mockApi }));
beforeEach(() => vi.clearAllMocks());
describe('admin/ocr — load', () => {
it('returns trainingInfo from API', async () => {
mockApi.GET.mockResolvedValue({
response: { ok: true },
data: { availableBlocks: 10, ocrServiceAvailable: true, senderModels: [] }
});
const result = (await load({ fetch } as never))!;
expect(result.trainingInfo.availableBlocks).toBe(10);
expect(result.trainingInfo.ocrServiceAvailable).toBe(true);
});
it('throws 503 when OCR API call fails', async () => {
mockApi.GET.mockResolvedValue({ response: { ok: false, status: 503 }, error: {} });
await expect(load({ fetch } as never)).rejects.toMatchObject({ status: 503 });
});
});