feat: integrate VIN capture with vehicle form (refs #68)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m12s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 32s
Deploy to Staging / Verify Staging (pull_request) Successful in 2m19s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped

- Add VinCameraButton component that opens CameraCapture with VIN guidance
- Add VinOcrReviewModal showing extracted VIN and decoded vehicle data
  - Confidence indicators (high/medium/low) for each field
  - Mobile-responsive bottom sheet on small screens
  - Accept, Edit Manually, or Retake Photo options
- Add useVinOcr hook orchestrating OCR extraction and NHTSA decode
- Update VehicleForm with camera button next to VIN input
- Form auto-populates with OCR result and decoded data on accept

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-02-01 20:17:56 -06:00
parent e1d12d049a
commit d6e74d89b3
4 changed files with 816 additions and 7 deletions

View 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>
);
};