Merge pull request 'feat: VIN Capture Integration (#68)' (#76) from issue-68-vin-capture-integration into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 30s
Deploy to Staging / Deploy to Staging (push) Successful in 31s
Deploy to Staging / Verify Staging (push) Successful in 2m19s
Deploy to Staging / Notify Staging Ready (push) Successful in 7s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
All checks were successful
Deploy to Staging / Build Images (push) Successful in 30s
Deploy to Staging / Deploy to Staging (push) Successful in 31s
Deploy to Staging / Verify Staging (push) Successful in 2m19s
Deploy to Staging / Notify Staging Ready (push) Successful in 7s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Reviewed-on: #76
This commit was merged in pull request #76.
This commit is contained in:
@@ -12,6 +12,9 @@ import { vehiclesApi } from '../api/vehicles.api';
|
||||
import { VehicleImageUpload } from './VehicleImageUpload';
|
||||
import { useTierAccess } from '../../../core/hooks/useTierAccess';
|
||||
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
|
||||
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 [decodeError, setDecodeError] = useState<string | null>(null);
|
||||
|
||||
// VIN OCR capture hook
|
||||
const vinOcr = useVinOcr();
|
||||
|
||||
// Tier access check for VIN decode feature
|
||||
const { hasAccess: canDecodeVin } = useTierAccess();
|
||||
const hasVinDecodeAccess = canDecodeVin('vehicle.vinDecode');
|
||||
@@ -426,6 +432,107 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
// Watch VIN for decode button
|
||||
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
|
||||
* 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>
|
||||
</label>
|
||||
<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>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="flex-1 flex gap-2">
|
||||
<input
|
||||
{...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"
|
||||
placeholder="Enter 17-character VIN"
|
||||
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
|
||||
type="button"
|
||||
onClick={handleDecodeVin}
|
||||
@@ -581,6 +699,9 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
{decodeError && (
|
||||
<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 && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-titanio">
|
||||
VIN decode requires Pro or Enterprise subscription
|
||||
@@ -880,6 +1001,16 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
open={showUpgradeDialog}
|
||||
onClose={() => setShowUpgradeDialog(false)}
|
||||
/>
|
||||
|
||||
{/* VIN OCR Review Modal */}
|
||||
<VinOcrReviewModal
|
||||
open={!!vinOcr.result}
|
||||
result={vinOcr.result}
|
||||
onAccept={handleAcceptVinOcr}
|
||||
onEdit={handleEditVinManually}
|
||||
onRetake={handleRetakeVinPhoto}
|
||||
onClose={vinOcr.reset}
|
||||
/>
|
||||
</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