feat(admin): add OcrHealthBar, OcrStatCards, OcrModelsTable components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
14
frontend/src/routes/admin/ocr/OcrHealthBar.svelte
Normal file
14
frontend/src/routes/admin/ocr/OcrHealthBar.svelte
Normal 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>
|
||||
18
frontend/src/routes/admin/ocr/OcrHealthBar.svelte.spec.ts
Normal file
18
frontend/src/routes/admin/ocr/OcrHealthBar.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
67
frontend/src/routes/admin/ocr/OcrModelsTable.svelte
Normal file
67
frontend/src/routes/admin/ocr/OcrModelsTable.svelte
Normal 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>
|
||||
44
frontend/src/routes/admin/ocr/OcrModelsTable.svelte.spec.ts
Normal file
44
frontend/src/routes/admin/ocr/OcrModelsTable.svelte.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
36
frontend/src/routes/admin/ocr/OcrStatCards.svelte
Normal file
36
frontend/src/routes/admin/ocr/OcrStatCards.svelte
Normal 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>
|
||||
30
frontend/src/routes/admin/ocr/OcrStatCards.svelte.spec.ts
Normal file
30
frontend/src/routes/admin/ocr/OcrStatCards.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user