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
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:
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user