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 } from 'react';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { applyOCRToVehicle, type OCRResultResponse } from '@/lib/ocr';
interface OCRDetailProps {
result: OCRResultResponse;
onApplyComplete?: () => void;
}
export function OCRDetail({ result, onApplyComplete }: OCRDetailProps) {
const [applying, setApplying] = useState(false);
const [applyError, setApplyError] = useState<string | null>(null);
const [applySuccess, setApplySuccess] = useState<string | null>(null);
const handleApply = async () => {
setApplying(true);
setApplyError(null);
setApplySuccess(null);
try {
const response = await applyOCRToVehicle(result.id);
setApplySuccess(`Applied ${response.updated_fields.length} fields to vehicle`);
if (onApplyComplete) onApplyComplete();
} catch (err: unknown) {
const apiErr = err as { error?: { message?: string } };
setApplyError(apiErr?.error?.message || 'Failed to apply OCR data');
} finally {
setApplying(false);
}
};
const data = result.structured_data;
const canApply = result.vehicle_id && result.structured_data && result.status === 'completed';
return (
<div data-testid="ocr-detail" className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Original Scan Side */}
<Card title="Original Scan">
<div data-testid="ocr-original-scan" className="space-y-2">
<p className="text-sm text-text-muted">File: {result.file_name}</p>
<p className="text-sm text-text-muted">Type: {result.mime_type}</p>
<p className="text-sm text-text-muted">Status: {result.status}</p>
{result.confidence_score != null && (
<p className="text-sm text-text-muted">
Confidence: {(result.confidence_score * 100).toFixed(1)}%
</p>
)}
{result.error_message && (
<p className="text-sm text-error">Error: {result.error_message}</p>
)}
{result.raw_text && (
<div className="mt-4">
<p className="text-sm font-medium text-text mb-1">Raw Text:</p>
<pre data-testid="ocr-raw-text" className="text-xs bg-gray-50 p-3 rounded overflow-auto max-h-48">
{result.raw_text}
</pre>
</div>
)}
</div>
</Card>
{/* Extracted Data Side */}
<Card title="Extracted Data">
<div data-testid="ocr-extracted-data" className="space-y-2">
{data ? (
<dl className="space-y-2">
<div className="flex justify-between">
<dt className="text-sm font-medium text-text">Brand:</dt>
<dd className="text-sm text-text-muted" data-testid="ocr-field-brand">{data.brand || '-'}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm font-medium text-text">Model:</dt>
<dd className="text-sm text-text-muted" data-testid="ocr-field-model">{data.model || '-'}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm font-medium text-text">VIN:</dt>
<dd className="text-sm text-text-muted" data-testid="ocr-field-vin">{data.vin || '-'}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm font-medium text-text">First Registration:</dt>
<dd className="text-sm text-text-muted" data-testid="ocr-field-first_registration">{data.first_registration || '-'}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm font-medium text-text">Mileage:</dt>
<dd className="text-sm text-text-muted" data-testid="ocr-field-mileage">{data.mileage != null ? `${data.mileage} km` : '-'}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm font-medium text-text">Power (kW):</dt>
<dd className="text-sm text-text-muted" data-testid="ocr-field-power_kw">{data.power_kw != null ? `${data.power_kw} kW` : '-'}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm font-medium text-text">Fuel Type:</dt>
<dd className="text-sm text-text-muted" data-testid="ocr-field-fuel_type">{data.fuel_type || '-'}</dd>
</div>
</dl>
) : (
<p className="text-sm text-text-muted">No structured data available yet.</p>
)}
{canApply && (
<div className="mt-4">
<Button
data-testid="ocr-apply-button"
onClick={handleApply}
loading={applying}
>
Apply to Vehicle
</Button>
</div>
)}
{applyError && (
<div data-testid="ocr-apply-error" className="mt-2 p-3 bg-error/10 text-error rounded text-sm">
{applyError}
</div>
)}
{applySuccess && (
<div data-testid="ocr-apply-success" className="mt-2 p-3 bg-green-50 text-green-700 rounded text-sm">
{applySuccess}
</div>
)}
</div>
</Card>
</div>
</div>
);
}
+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>
);
}
+150
View File
@@ -0,0 +1,150 @@
'use client';
import { useState, useCallback, useRef } from 'react';
import { Button } from '@/components/ui/Button';
import { uploadOCRScan } from '@/lib/ocr';
interface OCRUploadProps {
vehicleId?: string;
onUploadComplete?: (resultId: string) => void;
}
export function OCRUpload({ vehicleId, onUploadComplete }: OCRUploadProps) {
const [isDragging, setIsDragging] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFile = useCallback(
async (file: File) => {
setError(null);
setSuccess(null);
if (!file.type.startsWith('image/')) {
setError('Only image files are allowed');
return;
}
setUploading(true);
try {
const result = await uploadOCRScan(file, vehicleId);
setSuccess(`Upload queued. Result ID: ${result.ocr_result_id}`);
if (onUploadComplete) {
onUploadComplete(result.ocr_result_id);
}
} catch (err: unknown) {
const apiErr = err as { error?: { message?: string } };
setError(apiErr?.error?.message || 'Upload failed');
} finally {
setUploading(false);
}
},
[vehicleId, onUploadComplete]
);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = e.dataTransfer.files;
if (files && files.length > 0) {
handleFile(files[0]);
}
},
[handleFile]
);
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
handleFile(files[0]);
}
},
[handleFile]
);
return (
<div data-testid="ocr-upload" className="space-y-4">
<div
data-testid="ocr-dropzone"
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`
border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors
${isDragging ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'}
`}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileSelect}
className="hidden"
data-testid="ocr-file-input"
/>
<div className="space-y-2">
<svg
className="mx-auto h-12 w-12 text-text-muted"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p className="text-text font-medium">
{isDragging ? 'Drop image here' : 'Drag and drop scan image here'}
</p>
<p className="text-sm text-text-muted">or click to browse</p>
<p className="text-xs text-text-muted">PNG, JPEG, WebP (max 50 MB)</p>
</div>
</div>
{uploading && (
<div data-testid="ocr-uploading" className="text-center text-text-muted">
Uploading...
</div>
)}
{error && (
<div data-testid="ocr-upload-error" className="p-4 bg-error/10 text-error rounded-lg">
{error}
</div>
)}
{success && (
<div data-testid="ocr-upload-success" className="p-4 bg-green-50 text-green-700 rounded-lg">
{success}
</div>
)}
</div>
);
}