feat(T03): OCR-Erfassung via OpenRouter Qwen2.5-VL + OCR UI with drag-and-drop
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user