feat(admin): add OcrHealthBar, OcrStatCards, OcrModelsTable components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-18 00:30:24 +02:00
parent 5f4e60a14c
commit 0d8ac46639
6 changed files with 209 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
<script lang="ts">
let { ocrServiceAvailable }: { ocrServiceAvailable: boolean } = $props();
</script>
<div class="flex items-center gap-2">
<span
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'}
</span>
</div>

View File

@@ -0,0 +1,18 @@
import { afterEach, describe, it, expect } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import OcrHealthBar from './OcrHealthBar.svelte';
afterEach(cleanup);
describe('OcrHealthBar', () => {
it('shows online status when OCR service is available', async () => {
render(OcrHealthBar, { ocrServiceAvailable: true });
await expect.element(page.getByText(/online/i)).toBeInTheDocument();
});
it('shows offline status when OCR service is unavailable', async () => {
render(OcrHealthBar, { ocrServiceAvailable: false });
await expect.element(page.getByText(/offline/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,67 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
type SenderModel = components['schemas']['SenderModel'];
let {
senderModels,
personNames
}: {
senderModels: SenderModel[];
personNames: Record<string, string>;
} = $props();
</script>
<div class="border-brand-sand rounded-sm border bg-white p-6 shadow-sm">
<table class="w-full text-sm">
<thead>
<tr>
<th
class="border-brand-sand border-b pb-3 text-left text-xs font-bold tracking-widest text-gray-400 uppercase"
>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
>
<th
class="border-brand-sand border-b pb-3 text-left text-xs font-bold tracking-widest text-gray-400 uppercase"
>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
>
<th
class="border-brand-sand border-b pb-3 text-left text-xs font-bold tracking-widest text-gray-400 uppercase"
>Actions</th
>
</tr>
</thead>
<tbody>
{#each senderModels as model (model.id)}
<tr>
<td class="border-brand-sand/50 border-b py-3">
<a href="/admin/ocr/{model.personId}" class="text-brand-navy hover:underline">
{personNames[model.personId] ?? model.personId}
</a>
</td>
<td class="border-brand-sand/50 border-b py-3">
{model.cer != null ? (model.cer * 100).toFixed(1) + '%' : '—'}
</td>
<td class="border-brand-sand/50 border-b py-3">
{model.accuracy != null ? (model.accuracy * 100).toFixed(1) + '%' : '—'}
</td>
<td class="border-brand-sand/50 border-b py-3">
{model.correctedLinesAtTraining}
</td>
<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
>
</td>
</tr>
{/each}
</tbody>
</table>
</div>

View File

@@ -0,0 +1,44 @@
import { afterEach, describe, it, expect } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import OcrModelsTable from './OcrModelsTable.svelte';
import type { components } from '$lib/generated/api';
afterEach(cleanup);
type SenderModel = components['schemas']['SenderModel'];
const personId = '123e4567-e89b-12d3-a456-426614174000';
const model: SenderModel = {
id: 'aaa00000-0000-0000-0000-000000000001',
personId,
correctedLinesAtTraining: 120,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-06-01T00:00:00Z',
cer: 0.04,
accuracy: 0.96
};
describe('OcrModelsTable', () => {
it('shows person name when provided', async () => {
render(OcrModelsTable, {
senderModels: [model],
personNames: { [personId]: 'Anna Müller' }
});
await expect.element(page.getByText('Anna Müller')).toBeInTheDocument();
});
it('shows person ID when name is missing', async () => {
render(OcrModelsTable, {
senderModels: [model],
personNames: {}
});
await expect.element(page.getByText(personId)).toBeInTheDocument();
});
it('shows empty state when no models', async () => {
render(OcrModelsTable, { senderModels: [], personNames: {} });
const rows = document.querySelectorAll('tbody tr');
expect(rows.length).toBe(0);
});
});

View File

@@ -0,0 +1,36 @@
<script lang="ts">
interface Props {
availableBlocks?: number;
totalOcrBlocks?: number;
availableDocuments?: number;
availableSegBlocks?: number;
}
let {
availableBlocks = 0,
totalOcrBlocks = 0,
availableDocuments = 0,
availableSegBlocks = 0
}: Props = $props();
</script>
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<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
</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>
<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>
<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>
</div>

View File

@@ -0,0 +1,30 @@
import { afterEach, describe, it, expect } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import OcrStatCards from './OcrStatCards.svelte';
afterEach(cleanup);
const stats = {
availableBlocks: 42,
totalOcrBlocks: 200,
availableDocuments: 15,
availableSegBlocks: 8
};
describe('OcrStatCards', () => {
it('shows available block count', async () => {
render(OcrStatCards, stats);
await expect.element(page.getByText('42')).toBeInTheDocument();
});
it('shows total OCR block count', async () => {
render(OcrStatCards, stats);
await expect.element(page.getByText('200')).toBeInTheDocument();
});
it('shows available document count', async () => {
render(OcrStatCards, stats);
await expect.element(page.getByText('15')).toBeInTheDocument();
});
});