feat: VIN Capture Integration (#68) #76
@@ -12,6 +12,9 @@ import { vehiclesApi } from '../api/vehicles.api';
|
|||||||
import { VehicleImageUpload } from './VehicleImageUpload';
|
import { VehicleImageUpload } from './VehicleImageUpload';
|
||||||
import { useTierAccess } from '../../../core/hooks/useTierAccess';
|
import { useTierAccess } from '../../../core/hooks/useTierAccess';
|
||||||
import { UpgradeRequiredDialog } from '../../../shared-minimal/components/UpgradeRequiredDialog';
|
import { UpgradeRequiredDialog } from '../../../shared-minimal/components/UpgradeRequiredDialog';
|
||||||
|
import { VinCameraButton } from './VinCameraButton';
|
||||||
|
import { VinOcrReviewModal } from './VinOcrReviewModal';
|
||||||
|
import { useVinOcr } from '../hooks/useVinOcr';
|
||||||
|
|
||||||
// Helper to convert NaN (from empty number inputs) to null
|
// Helper to convert NaN (from empty number inputs) to null
|
||||||
const nanToNull = (val: unknown) => (typeof val === 'number' && isNaN(val) ? null : val);
|
const nanToNull = (val: unknown) => (typeof val === 'number' && isNaN(val) ? null : val);
|
||||||
@@ -112,6 +115,9 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|||||||
const [showUpgradeDialog, setShowUpgradeDialog] = useState(false);
|
const [showUpgradeDialog, setShowUpgradeDialog] = useState(false);
|
||||||
const [decodeError, setDecodeError] = useState<string | null>(null);
|
const [decodeError, setDecodeError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// VIN OCR capture hook
|
||||||
|
const vinOcr = useVinOcr();
|
||||||
|
|
||||||
// Tier access check for VIN decode feature
|
// Tier access check for VIN decode feature
|
||||||
const { hasAccess: canDecodeVin } = useTierAccess();
|
const { hasAccess: canDecodeVin } = useTierAccess();
|
||||||
const hasVinDecodeAccess = canDecodeVin('vehicle.vinDecode');
|
const hasVinDecodeAccess = canDecodeVin('vehicle.vinDecode');
|
||||||
@@ -426,6 +432,107 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|||||||
// Watch VIN for decode button
|
// Watch VIN for decode button
|
||||||
const watchedVin = watch('vin');
|
const watchedVin = watch('vin');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle accepting VIN OCR result
|
||||||
|
* Populates VIN and decoded fields into the form
|
||||||
|
*/
|
||||||
|
const handleAcceptVinOcr = async () => {
|
||||||
|
const result = vinOcr.acceptResult();
|
||||||
|
if (!result) return;
|
||||||
|
|
||||||
|
const { ocrResult, decodedVehicle } = result;
|
||||||
|
|
||||||
|
// Set the VIN immediately
|
||||||
|
setValue('vin', ocrResult.vin);
|
||||||
|
|
||||||
|
// If we have decoded vehicle data, populate the form similar to handleDecodeVin
|
||||||
|
if (decodedVehicle) {
|
||||||
|
// Prevent cascade useEffects from clearing values
|
||||||
|
isVinDecoding.current = true;
|
||||||
|
setLoadingDropdowns(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Determine final values
|
||||||
|
const yearValue = decodedVehicle.year.value;
|
||||||
|
const makeValue = decodedVehicle.make.value;
|
||||||
|
const modelValue = decodedVehicle.model.value;
|
||||||
|
const trimValue = decodedVehicle.trimLevel.value;
|
||||||
|
|
||||||
|
// Load dropdown options hierarchically
|
||||||
|
if (yearValue) {
|
||||||
|
prevYear.current = yearValue;
|
||||||
|
const makesData = await vehiclesApi.getMakes(yearValue);
|
||||||
|
setMakes(makesData);
|
||||||
|
|
||||||
|
if (makeValue) {
|
||||||
|
prevMake.current = makeValue;
|
||||||
|
const modelsData = await vehiclesApi.getModels(yearValue, makeValue);
|
||||||
|
setModels(modelsData);
|
||||||
|
|
||||||
|
if (modelValue) {
|
||||||
|
prevModel.current = modelValue;
|
||||||
|
const trimsData = await vehiclesApi.getTrims(yearValue, makeValue, modelValue);
|
||||||
|
setTrims(trimsData);
|
||||||
|
|
||||||
|
if (trimValue) {
|
||||||
|
prevTrim.current = trimValue;
|
||||||
|
const [enginesData, transmissionsData] = await Promise.all([
|
||||||
|
vehiclesApi.getEngines(yearValue, makeValue, modelValue, trimValue),
|
||||||
|
vehiclesApi.getTransmissions(yearValue, makeValue, modelValue, trimValue),
|
||||||
|
]);
|
||||||
|
setEngines(enginesData);
|
||||||
|
setTransmissions(transmissionsData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set form values after options are loaded
|
||||||
|
if (decodedVehicle.year.value) {
|
||||||
|
setValue('year', decodedVehicle.year.value);
|
||||||
|
}
|
||||||
|
if (decodedVehicle.make.value) {
|
||||||
|
setValue('make', decodedVehicle.make.value);
|
||||||
|
}
|
||||||
|
if (decodedVehicle.model.value) {
|
||||||
|
setValue('model', decodedVehicle.model.value);
|
||||||
|
}
|
||||||
|
if (decodedVehicle.trimLevel.value) {
|
||||||
|
setValue('trimLevel', decodedVehicle.trimLevel.value);
|
||||||
|
}
|
||||||
|
if (decodedVehicle.engine.value) {
|
||||||
|
setValue('engine', decodedVehicle.engine.value);
|
||||||
|
}
|
||||||
|
if (decodedVehicle.transmission.value) {
|
||||||
|
setValue('transmission', decodedVehicle.transmission.value);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoadingDropdowns(false);
|
||||||
|
isVinDecoding.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle editing manually after VIN OCR
|
||||||
|
* Just sets the VIN and closes the modal
|
||||||
|
*/
|
||||||
|
const handleEditVinManually = () => {
|
||||||
|
const result = vinOcr.acceptResult();
|
||||||
|
if (result) {
|
||||||
|
setValue('vin', result.ocrResult.vin);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle retaking VIN photo
|
||||||
|
* Resets and restarts capture
|
||||||
|
*/
|
||||||
|
const handleRetakeVinPhoto = () => {
|
||||||
|
vinOcr.reset();
|
||||||
|
vinOcr.startCapture();
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle VIN decode button click
|
* Handle VIN decode button click
|
||||||
* Calls NHTSA API and populates empty form fields
|
* Calls NHTSA API and populates empty form fields
|
||||||
@@ -546,15 +653,26 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|||||||
VIN Number <span className="text-red-500">*</span>
|
VIN Number <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<p className="text-xs text-gray-600 dark:text-titanio mb-2">
|
<p className="text-xs text-gray-600 dark:text-titanio mb-2">
|
||||||
Enter vehicle VIN (optional if License Plate provided)
|
Enter vehicle VIN or scan with camera (optional if License Plate provided)
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<div className="flex-1 flex gap-2">
|
||||||
<input
|
<input
|
||||||
{...register('vin')}
|
{...register('vin')}
|
||||||
className="flex-1 px-3 py-2 border rounded-md text-base bg-white text-gray-900 border-gray-300 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:placeholder-canna dark:focus:ring-abudhabi dark:focus:border-abudhabi"
|
className="flex-1 px-3 py-2 border rounded-md text-base bg-white text-gray-900 border-gray-300 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:placeholder-canna dark:focus:ring-abudhabi dark:focus:border-abudhabi"
|
||||||
placeholder="Enter 17-character VIN"
|
placeholder="Enter 17-character VIN"
|
||||||
style={{ fontSize: '16px' }}
|
style={{ fontSize: '16px' }}
|
||||||
/>
|
/>
|
||||||
|
<VinCameraButton
|
||||||
|
disabled={loading || loadingDropdowns}
|
||||||
|
isCapturing={vinOcr.isCapturing}
|
||||||
|
isProcessing={vinOcr.isProcessing}
|
||||||
|
processingStep={vinOcr.processingStep}
|
||||||
|
onStartCapture={vinOcr.startCapture}
|
||||||
|
onCancelCapture={vinOcr.cancelCapture}
|
||||||
|
onImageCapture={vinOcr.processImage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDecodeVin}
|
onClick={handleDecodeVin}
|
||||||
@@ -581,6 +699,9 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|||||||
{decodeError && (
|
{decodeError && (
|
||||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{decodeError}</p>
|
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{decodeError}</p>
|
||||||
)}
|
)}
|
||||||
|
{vinOcr.error && (
|
||||||
|
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{vinOcr.error}</p>
|
||||||
|
)}
|
||||||
{!hasVinDecodeAccess && (
|
{!hasVinDecodeAccess && (
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-titanio">
|
<p className="mt-1 text-xs text-gray-500 dark:text-titanio">
|
||||||
VIN decode requires Pro or Enterprise subscription
|
VIN decode requires Pro or Enterprise subscription
|
||||||
@@ -880,6 +1001,16 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|||||||
open={showUpgradeDialog}
|
open={showUpgradeDialog}
|
||||||
onClose={() => setShowUpgradeDialog(false)}
|
onClose={() => setShowUpgradeDialog(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* VIN OCR Review Modal */}
|
||||||
|
<VinOcrReviewModal
|
||||||
|
open={!!vinOcr.result}
|
||||||
|
result={vinOcr.result}
|
||||||
|
onAccept={handleAcceptVinOcr}
|
||||||
|
onEdit={handleEditVinManually}
|
||||||
|
onRetake={handleRetakeVinPhoto}
|
||||||
|
onClose={vinOcr.reset}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
132
frontend/src/features/vehicles/components/VinCameraButton.tsx
Normal file
132
frontend/src/features/vehicles/components/VinCameraButton.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Camera button for VIN capture that opens CameraCapture modal
|
||||||
|
* @ai-context Renders camera icon button and full-screen camera overlay
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { IconButton, Tooltip, Modal, Box, CircularProgress, Typography } from '@mui/material';
|
||||||
|
import CameraAltIcon from '@mui/icons-material/CameraAlt';
|
||||||
|
import { CameraCapture } from '../../../shared/components/CameraCapture/CameraCapture';
|
||||||
|
|
||||||
|
interface VinCameraButtonProps {
|
||||||
|
disabled?: boolean;
|
||||||
|
isCapturing: boolean;
|
||||||
|
isProcessing: boolean;
|
||||||
|
processingStep: 'idle' | 'extracting' | 'decoding';
|
||||||
|
onStartCapture: () => void;
|
||||||
|
onCancelCapture: () => void;
|
||||||
|
onImageCapture: (file: File, croppedFile?: File) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VinCameraButton: React.FC<VinCameraButtonProps> = ({
|
||||||
|
disabled,
|
||||||
|
isCapturing,
|
||||||
|
isProcessing,
|
||||||
|
processingStep,
|
||||||
|
onStartCapture,
|
||||||
|
onCancelCapture,
|
||||||
|
onImageCapture,
|
||||||
|
}) => {
|
||||||
|
const getProcessingMessage = () => {
|
||||||
|
switch (processingStep) {
|
||||||
|
case 'extracting':
|
||||||
|
return 'Extracting VIN...';
|
||||||
|
case 'decoding':
|
||||||
|
return 'Decoding vehicle info...';
|
||||||
|
default:
|
||||||
|
return 'Processing...';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip title="Scan VIN with camera">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
onClick={onStartCapture}
|
||||||
|
disabled={disabled || isProcessing}
|
||||||
|
size="large"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: 'primary.main',
|
||||||
|
color: 'white',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'primary.dark',
|
||||||
|
},
|
||||||
|
'&.Mui-disabled': {
|
||||||
|
backgroundColor: 'action.disabledBackground',
|
||||||
|
color: 'action.disabled',
|
||||||
|
},
|
||||||
|
minWidth: 44,
|
||||||
|
minHeight: 44,
|
||||||
|
}}
|
||||||
|
aria-label="Scan VIN with camera"
|
||||||
|
>
|
||||||
|
{isProcessing ? (
|
||||||
|
<CircularProgress size={24} color="inherit" />
|
||||||
|
) : (
|
||||||
|
<CameraAltIcon />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Full-screen camera modal */}
|
||||||
|
<Modal
|
||||||
|
open={isCapturing}
|
||||||
|
onClose={onCancelCapture}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: 'black',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CameraCapture
|
||||||
|
onCapture={onImageCapture}
|
||||||
|
onCancel={onCancelCapture}
|
||||||
|
guidanceType="vin"
|
||||||
|
allowCrop={true}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Processing overlay */}
|
||||||
|
<Modal
|
||||||
|
open={isProcessing}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 2,
|
||||||
|
p: 4,
|
||||||
|
backgroundColor: 'background.paper',
|
||||||
|
borderRadius: 2,
|
||||||
|
boxShadow: 24,
|
||||||
|
outline: 'none',
|
||||||
|
minWidth: 200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress size={48} />
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
{getProcessingMessage()}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
372
frontend/src/features/vehicles/components/VinOcrReviewModal.tsx
Normal file
372
frontend/src/features/vehicles/components/VinOcrReviewModal.tsx
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Modal to review VIN OCR results and decoded vehicle data
|
||||||
|
* @ai-context Shows extracted VIN with confidence, decoded fields, accept/edit/retake options
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Alert,
|
||||||
|
useTheme,
|
||||||
|
useMediaQuery,
|
||||||
|
Drawer,
|
||||||
|
Divider,
|
||||||
|
} from '@mui/material';
|
||||||
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
|
import WarningIcon from '@mui/icons-material/Warning';
|
||||||
|
import ErrorIcon from '@mui/icons-material/Error';
|
||||||
|
import CameraAltIcon from '@mui/icons-material/CameraAlt';
|
||||||
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
import { VinCaptureResult } from '../hooks/useVinOcr';
|
||||||
|
import { MatchConfidence } from '../types/vehicles.types';
|
||||||
|
|
||||||
|
interface VinOcrReviewModalProps {
|
||||||
|
open: boolean;
|
||||||
|
result: VinCaptureResult | null;
|
||||||
|
onAccept: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onRetake: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get confidence level from percentage */
|
||||||
|
function getConfidenceLevel(confidence: number): 'high' | 'medium' | 'low' {
|
||||||
|
if (confidence >= 0.9) return 'high';
|
||||||
|
if (confidence >= 0.7) return 'medium';
|
||||||
|
return 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confidence indicator component */
|
||||||
|
const ConfidenceIndicator: React.FC<{ level: 'high' | 'medium' | 'low' | 'none' }> = ({
|
||||||
|
level,
|
||||||
|
}) => {
|
||||||
|
const configs = {
|
||||||
|
high: { color: 'success.main', icon: CheckCircleIcon, label: 'High' },
|
||||||
|
medium: { color: 'warning.main', icon: WarningIcon, label: 'Medium' },
|
||||||
|
low: { color: 'error.light', icon: ErrorIcon, label: 'Low' },
|
||||||
|
none: { color: 'text.disabled', icon: ErrorIcon, label: 'N/A' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = configs[level];
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon sx={{ fontSize: 16, color: config.color }} />
|
||||||
|
<Typography variant="caption" sx={{ color: config.color }}>
|
||||||
|
{config.label}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Map match confidence to display level */
|
||||||
|
function matchConfidenceToLevel(confidence: MatchConfidence): 'high' | 'medium' | 'low' | 'none' {
|
||||||
|
switch (confidence) {
|
||||||
|
case 'high':
|
||||||
|
return 'high';
|
||||||
|
case 'medium':
|
||||||
|
return 'medium';
|
||||||
|
case 'none':
|
||||||
|
return 'none';
|
||||||
|
default:
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decoded field row component */
|
||||||
|
const DecodedFieldRow: React.FC<{
|
||||||
|
label: string;
|
||||||
|
value: string | number | null;
|
||||||
|
nhtsaValue: string | null;
|
||||||
|
confidence: MatchConfidence;
|
||||||
|
}> = ({ label, value, nhtsaValue, confidence }) => {
|
||||||
|
const displayValue = value || nhtsaValue || '-';
|
||||||
|
const level = matchConfidenceToLevel(confidence);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
py: 1,
|
||||||
|
px: 2,
|
||||||
|
backgroundColor: level === 'low' || level === 'none' ? 'action.hover' : 'transparent',
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" fontWeight={value ? 500 : 400}>
|
||||||
|
{displayValue}
|
||||||
|
</Typography>
|
||||||
|
{nhtsaValue && value !== nhtsaValue && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
NHTSA: {nhtsaValue}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<ConfidenceIndicator level={level} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Main modal content */
|
||||||
|
const ReviewContent: React.FC<{
|
||||||
|
result: VinCaptureResult;
|
||||||
|
onAccept: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onRetake: () => void;
|
||||||
|
}> = ({ result, onAccept, onEdit, onRetake }) => {
|
||||||
|
const { ocrResult, decodedVehicle, decodeError } = result;
|
||||||
|
const vinConfidenceLevel = getConfidenceLevel(ocrResult.confidence);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* VIN Section */}
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||||
|
Detected VIN
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
p: 2,
|
||||||
|
backgroundColor: 'action.hover',
|
||||||
|
borderRadius: 1,
|
||||||
|
border: 1,
|
||||||
|
borderColor: vinConfidenceLevel === 'high' ? 'success.main' : 'warning.main',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
fontFamily="monospace"
|
||||||
|
letterSpacing={1}
|
||||||
|
sx={{ wordBreak: 'break-all' }}
|
||||||
|
>
|
||||||
|
{ocrResult.vin}
|
||||||
|
</Typography>
|
||||||
|
<ConfidenceIndicator level={vinConfidenceLevel} />
|
||||||
|
</Box>
|
||||||
|
{vinConfidenceLevel !== 'high' && (
|
||||||
|
<Typography variant="caption" color="warning.main" sx={{ mt: 1, display: 'block' }}>
|
||||||
|
Low confidence detection - please verify the VIN is correct
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Decode Error */}
|
||||||
|
{decodeError && (
|
||||||
|
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||||
|
{decodeError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Decoded Vehicle Information */}
|
||||||
|
{decodedVehicle && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||||
|
Decoded Vehicle Information
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
border: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DecodedFieldRow
|
||||||
|
label="Year"
|
||||||
|
value={decodedVehicle.year.value}
|
||||||
|
nhtsaValue={decodedVehicle.year.nhtsaValue}
|
||||||
|
confidence={decodedVehicle.year.confidence}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<DecodedFieldRow
|
||||||
|
label="Make"
|
||||||
|
value={decodedVehicle.make.value}
|
||||||
|
nhtsaValue={decodedVehicle.make.nhtsaValue}
|
||||||
|
confidence={decodedVehicle.make.confidence}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<DecodedFieldRow
|
||||||
|
label="Model"
|
||||||
|
value={decodedVehicle.model.value}
|
||||||
|
nhtsaValue={decodedVehicle.model.nhtsaValue}
|
||||||
|
confidence={decodedVehicle.model.confidence}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<DecodedFieldRow
|
||||||
|
label="Trim"
|
||||||
|
value={decodedVehicle.trimLevel.value}
|
||||||
|
nhtsaValue={decodedVehicle.trimLevel.nhtsaValue}
|
||||||
|
confidence={decodedVehicle.trimLevel.confidence}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<DecodedFieldRow
|
||||||
|
label="Engine"
|
||||||
|
value={decodedVehicle.engine.value}
|
||||||
|
nhtsaValue={decodedVehicle.engine.nhtsaValue}
|
||||||
|
confidence={decodedVehicle.engine.confidence}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<DecodedFieldRow
|
||||||
|
label="Transmission"
|
||||||
|
value={decodedVehicle.transmission.value}
|
||||||
|
nhtsaValue={decodedVehicle.transmission.nhtsaValue}
|
||||||
|
confidence={decodedVehicle.transmission.confidence}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mt: 2, display: 'block' }}>
|
||||||
|
Fields with lower confidence may need manual verification.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No decoded data - VIN only mode */}
|
||||||
|
{!decodedVehicle && !decodeError && (
|
||||||
|
<Alert severity="info">
|
||||||
|
VIN extracted successfully. Vehicle details will need to be entered manually.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: { xs: 'column', sm: 'row' },
|
||||||
|
gap: 2,
|
||||||
|
mt: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<CameraAltIcon />}
|
||||||
|
onClick={onRetake}
|
||||||
|
fullWidth
|
||||||
|
sx={{ minHeight: 44 }}
|
||||||
|
>
|
||||||
|
Retake Photo
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<EditIcon />}
|
||||||
|
onClick={onEdit}
|
||||||
|
fullWidth
|
||||||
|
sx={{ minHeight: 44 }}
|
||||||
|
>
|
||||||
|
Edit Manually
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<CheckCircleIcon />}
|
||||||
|
onClick={onAccept}
|
||||||
|
fullWidth
|
||||||
|
sx={{ minHeight: 44 }}
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VinOcrReviewModal: React.FC<VinOcrReviewModalProps> = ({
|
||||||
|
open,
|
||||||
|
result,
|
||||||
|
onAccept,
|
||||||
|
onEdit,
|
||||||
|
onRetake,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
// Use bottom sheet on mobile, dialog on desktop
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
anchor="bottom"
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
maxHeight: '90vh',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
{/* Drag handle */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 32,
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: 'divider',
|
||||||
|
borderRadius: 2,
|
||||||
|
mx: 'auto',
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
VIN Detected
|
||||||
|
</Typography>
|
||||||
|
<ReviewContent
|
||||||
|
result={result}
|
||||||
|
onAccept={onAccept}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onRetake={onRetake}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop dialog
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
PaperProps={{
|
||||||
|
sx: { borderRadius: 2 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle>VIN Detected</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<ReviewContent
|
||||||
|
result={result}
|
||||||
|
onAccept={onAccept}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onRetake={onRetake}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ display: 'none' }}>
|
||||||
|
{/* Actions are in ReviewContent */}
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
174
frontend/src/features/vehicles/hooks/useVinOcr.ts
Normal file
174
frontend/src/features/vehicles/hooks/useVinOcr.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Hook to orchestrate VIN OCR extraction and NHTSA decode
|
||||||
|
* @ai-context Handles camera capture -> OCR extraction -> VIN decode flow
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { apiClient } from '../../../core/api/client';
|
||||||
|
import { vehiclesApi } from '../api/vehicles.api';
|
||||||
|
import { DecodedVehicleData } from '../types/vehicles.types';
|
||||||
|
|
||||||
|
/** OCR extraction result for VIN */
|
||||||
|
export interface VinOcrResult {
|
||||||
|
vin: string;
|
||||||
|
confidence: number;
|
||||||
|
rawText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Combined OCR + decode result */
|
||||||
|
export interface VinCaptureResult {
|
||||||
|
ocrResult: VinOcrResult;
|
||||||
|
decodedVehicle: DecodedVehicleData | null;
|
||||||
|
decodeError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hook state */
|
||||||
|
export interface UseVinOcrState {
|
||||||
|
isCapturing: boolean;
|
||||||
|
isProcessing: boolean;
|
||||||
|
processingStep: 'idle' | 'extracting' | 'decoding';
|
||||||
|
result: VinCaptureResult | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hook return type */
|
||||||
|
export interface UseVinOcrReturn extends UseVinOcrState {
|
||||||
|
startCapture: () => void;
|
||||||
|
cancelCapture: () => void;
|
||||||
|
processImage: (file: File, croppedFile?: File) => Promise<void>;
|
||||||
|
acceptResult: () => VinCaptureResult | null;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract VIN from image using OCR service
|
||||||
|
*/
|
||||||
|
async function extractVinFromImage(file: File): Promise<VinOcrResult> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await apiClient.post('/ocr/extract', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
timeout: 30000, // 30 seconds for OCR processing
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error('OCR extraction failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract VIN from the response
|
||||||
|
const vinField = data.extractedFields?.vin;
|
||||||
|
if (!vinField?.value) {
|
||||||
|
throw new Error('No VIN found in image. Please ensure the VIN is clearly visible.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
vin: vinField.value.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, ''),
|
||||||
|
confidence: vinField.confidence,
|
||||||
|
rawText: data.rawText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to orchestrate VIN photo capture, OCR, and decode
|
||||||
|
*/
|
||||||
|
export function useVinOcr(): UseVinOcrReturn {
|
||||||
|
const [isCapturing, setIsCapturing] = useState(false);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [processingStep, setProcessingStep] = useState<'idle' | 'extracting' | 'decoding'>('idle');
|
||||||
|
const [result, setResult] = useState<VinCaptureResult | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const startCapture = useCallback(() => {
|
||||||
|
setIsCapturing(true);
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cancelCapture = useCallback(() => {
|
||||||
|
setIsCapturing(false);
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const processImage = useCallback(async (file: File, croppedFile?: File) => {
|
||||||
|
setIsCapturing(false);
|
||||||
|
setIsProcessing(true);
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Extract VIN from image
|
||||||
|
setProcessingStep('extracting');
|
||||||
|
const imageToProcess = croppedFile || file;
|
||||||
|
const ocrResult = await extractVinFromImage(imageToProcess);
|
||||||
|
|
||||||
|
// Validate VIN format
|
||||||
|
if (ocrResult.vin.length !== 17) {
|
||||||
|
throw new Error(
|
||||||
|
`Extracted VIN "${ocrResult.vin}" is ${ocrResult.vin.length} characters. VIN must be exactly 17 characters.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Decode VIN using NHTSA
|
||||||
|
setProcessingStep('decoding');
|
||||||
|
let decodedVehicle: DecodedVehicleData | null = null;
|
||||||
|
let decodeError: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
decodedVehicle = await vehiclesApi.decodeVin(ocrResult.vin);
|
||||||
|
} catch (err: any) {
|
||||||
|
// VIN decode failure is not fatal - we still have the VIN
|
||||||
|
if (err.response?.data?.error === 'TIER_REQUIRED') {
|
||||||
|
decodeError = 'VIN decode requires Pro or Enterprise subscription';
|
||||||
|
} else if (err.response?.data?.error === 'INVALID_VIN') {
|
||||||
|
decodeError = 'VIN format is not recognized by NHTSA';
|
||||||
|
} else {
|
||||||
|
decodeError = 'Unable to decode vehicle information';
|
||||||
|
}
|
||||||
|
console.warn('VIN decode failed:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
setResult({
|
||||||
|
ocrResult,
|
||||||
|
decodedVehicle,
|
||||||
|
decodeError,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('VIN OCR processing failed:', err);
|
||||||
|
const message = err.response?.data?.message || err.message || 'Failed to process image';
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
setProcessingStep('idle');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const acceptResult = useCallback(() => {
|
||||||
|
const currentResult = result;
|
||||||
|
setResult(null);
|
||||||
|
return currentResult;
|
||||||
|
}, [result]);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setIsCapturing(false);
|
||||||
|
setIsProcessing(false);
|
||||||
|
setProcessingStep('idle');
|
||||||
|
setResult(null);
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isCapturing,
|
||||||
|
isProcessing,
|
||||||
|
processingStep,
|
||||||
|
result,
|
||||||
|
error,
|
||||||
|
startCapture,
|
||||||
|
cancelCapture,
|
||||||
|
processImage,
|
||||||
|
acceptResult,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user