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
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:
16
frontend/src/routes/admin/ocr/+page.server.ts
Normal file
16
frontend/src/routes/admin/ocr/+page.server.ts
Normal 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! };
|
||||
};
|
||||
42
frontend/src/routes/admin/ocr/+page.svelte
Normal file
42
frontend/src/routes/admin/ocr/+page.svelte
Normal 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>
|
||||
18
frontend/src/routes/admin/ocr/[personId]/+page.server.ts
Normal file
18
frontend/src/routes/admin/ocr/[personId]/+page.server.ts
Normal 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! };
|
||||
};
|
||||
31
frontend/src/routes/admin/ocr/[personId]/+page.svelte
Normal file
31
frontend/src/routes/admin/ocr/[personId]/+page.svelte
Normal 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>
|
||||
33
frontend/src/routes/admin/ocr/[personId]/page.server.spec.ts
Normal file
33
frontend/src/routes/admin/ocr/[personId]/page.server.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
16
frontend/src/routes/admin/ocr/global/+page.server.ts
Normal file
16
frontend/src/routes/admin/ocr/global/+page.server.ts
Normal 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! };
|
||||
};
|
||||
30
frontend/src/routes/admin/ocr/global/+page.svelte
Normal file
30
frontend/src/routes/admin/ocr/global/+page.svelte
Normal 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>
|
||||
27
frontend/src/routes/admin/ocr/global/page.server.spec.ts
Normal file
27
frontend/src/routes/admin/ocr/global/page.server.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
28
frontend/src/routes/admin/ocr/page.server.spec.ts
Normal file
28
frontend/src/routes/admin/ocr/page.server.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user