feat(T03): OCR-Erfassung via OpenRouter Qwen2.5-VL + OCR UI with drag-and-drop

This commit is contained in:
2026-07-17 00:45:14 +02:00
parent b128ea6b39
commit 149ac04dc1
20 changed files with 2348 additions and 182 deletions
+131
View File
@@ -0,0 +1,131 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Table } from '@/components/ui/Table';
import { Button } from '@/components/ui/Button';
import { listOCRResults, type OCRResultResponse } from '@/lib/ocr';
import type { PaginatedResponse } from '@/lib/api';
interface OCRResultsProps {
vehicleId?: string;
onSelectResult?: (result: OCRResultResponse) => void;
}
const STATUS_COLORS: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-800',
processing: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
manual_review: 'bg-orange-100 text-orange-800',
};
export function OCRResults({ vehicleId, onSelectResult }: OCRResultsProps) {
const [results, setResults] = useState<OCRResultResponse[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchResults = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data: PaginatedResponse<OCRResultResponse> = await listOCRResults(vehicleId, page, pageSize);
setResults(data.items);
setTotal(data.total);
} catch (err: unknown) {
const apiErr = err as { error?: { message?: string } };
setError(apiErr?.error?.message || 'Failed to load OCR results');
} finally {
setLoading(false);
}
}, [vehicleId, page, pageSize]);
useEffect(() => {
fetchResults();
}, [fetchResults]);
const columns = [
{
key: 'file_name',
label: 'File',
render: (row: OCRResultResponse) => (
<button
data-testid={`ocr-row-${row.id}`}
onClick={() => onSelectResult?.(row)}
className="text-primary hover:underline"
>
{row.file_name}
</button>
),
},
{ key: 'status', label: 'Status', render: (row: OCRResultResponse) => (
<span
data-testid={`ocr-status-${row.id}`}
className={`px-2 py-1 rounded text-xs font-medium ${STATUS_COLORS[row.status] || 'bg-gray-100 text-gray-800'}`}
>
{row.status}
</span>
)},
{ key: 'confidence_score', label: 'Confidence', render: (row: OCRResultResponse) =>
row.confidence_score != null
? `${(row.confidence_score * 100).toFixed(1)}%`
: '-'
},
{ key: 'created_at', label: 'Created', render: (row: OCRResultResponse) =>
row.created_at ? new Date(row.created_at).toLocaleDateString('de-DE') : '-'
},
];
const totalPages = Math.ceil(total / pageSize);
return (
<div data-testid="ocr-results" className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold text-text">OCR Results</h2>
<Button variant="secondary" onClick={fetchResults} loading={loading}>
Refresh
</Button>
</div>
{error && (
<div data-testid="ocr-results-error" className="p-4 bg-error/10 text-error rounded-lg">
{error}
</div>
)}
{loading ? (
<div data-testid="ocr-results-loading" className="text-center py-8 text-text-muted">
Loading OCR results...
</div>
) : (
<Table columns={columns} data={results} rowKey={row => row.id} />
)}
{totalPages > 1 && (
<div data-testid="ocr-results-pagination" className="flex items-center justify-between">
<span className="text-sm text-text-muted">
Page {page} of {totalPages} ({total} total)
</span>
<div className="flex gap-2">
<Button
variant="secondary"
disabled={page <= 1}
onClick={() => setPage(page - 1)}
>
Previous
</Button>
<Button
variant="secondary"
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
>
Next
</Button>
</div>
</div>
)}
</div>
);
}