feat: improve VIN confidence reporting and editable review dropdowns (refs #125)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 4m37s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 51s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 4m37s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 51s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
VIN OCR confidence now reflects recognition accuracy only (not match quality). Review modal replaces read-only fields with editable cascade dropdowns pre-populated from NHTSA decode, with NHTSA reference hints for unmatched fields. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { Button } from '../../../shared-minimal/components/Button';
|
import { Button } from '../../../shared-minimal/components/Button';
|
||||||
import { CreateVehicleRequest, Vehicle } from '../types/vehicles.types';
|
import { CreateVehicleRequest, Vehicle, VinReviewSelections } from '../types/vehicles.types';
|
||||||
import { vehiclesApi } from '../api/vehicles.api';
|
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';
|
||||||
@@ -433,52 +433,47 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|||||||
const watchedVin = watch('vin');
|
const watchedVin = watch('vin');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle accepting VIN OCR result
|
* Handle accepting VIN OCR result with user-edited selections from review modal
|
||||||
* Populates VIN and decoded fields into the form
|
* Populates VIN and selected dropdown values into the form
|
||||||
*/
|
*/
|
||||||
const handleAcceptVinOcr = async () => {
|
const handleAcceptVinOcr = async (selections: VinReviewSelections) => {
|
||||||
const result = vinOcr.acceptResult();
|
// Clear the OCR result state
|
||||||
if (!result) return;
|
vinOcr.acceptResult();
|
||||||
|
|
||||||
const { ocrResult, decodedVehicle } = result;
|
|
||||||
|
|
||||||
// Set the VIN immediately
|
// Set the VIN immediately
|
||||||
setValue('vin', ocrResult.vin);
|
setValue('vin', selections.vin);
|
||||||
|
|
||||||
// If we have decoded vehicle data, populate the form similar to handleDecodeVin
|
// Populate form with user's dropdown selections
|
||||||
if (decodedVehicle) {
|
const hasSelections = selections.year || selections.make || selections.model ||
|
||||||
|
selections.trimLevel || selections.engine || selections.transmission;
|
||||||
|
|
||||||
|
if (hasSelections) {
|
||||||
// Prevent cascade useEffects from clearing values
|
// Prevent cascade useEffects from clearing values
|
||||||
isVinDecoding.current = true;
|
isVinDecoding.current = true;
|
||||||
setLoadingDropdowns(true);
|
setLoadingDropdowns(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Determine final values
|
// Load dropdown options hierarchically for the selected values
|
||||||
const yearValue = decodedVehicle.year.value;
|
if (selections.year) {
|
||||||
const makeValue = decodedVehicle.make.value;
|
prevYear.current = selections.year;
|
||||||
const modelValue = decodedVehicle.model.value;
|
const makesData = await vehiclesApi.getMakes(selections.year);
|
||||||
const trimValue = decodedVehicle.trimLevel.value;
|
|
||||||
|
|
||||||
// Load dropdown options hierarchically
|
|
||||||
if (yearValue) {
|
|
||||||
prevYear.current = yearValue;
|
|
||||||
const makesData = await vehiclesApi.getMakes(yearValue);
|
|
||||||
setMakes(makesData);
|
setMakes(makesData);
|
||||||
|
|
||||||
if (makeValue) {
|
if (selections.make) {
|
||||||
prevMake.current = makeValue;
|
prevMake.current = selections.make;
|
||||||
const modelsData = await vehiclesApi.getModels(yearValue, makeValue);
|
const modelsData = await vehiclesApi.getModels(selections.year, selections.make);
|
||||||
setModels(modelsData);
|
setModels(modelsData);
|
||||||
|
|
||||||
if (modelValue) {
|
if (selections.model) {
|
||||||
prevModel.current = modelValue;
|
prevModel.current = selections.model;
|
||||||
const trimsData = await vehiclesApi.getTrims(yearValue, makeValue, modelValue);
|
const trimsData = await vehiclesApi.getTrims(selections.year, selections.make, selections.model);
|
||||||
setTrims(trimsData);
|
setTrims(trimsData);
|
||||||
|
|
||||||
if (trimValue) {
|
if (selections.trimLevel) {
|
||||||
prevTrim.current = trimValue;
|
prevTrim.current = selections.trimLevel;
|
||||||
const [enginesData, transmissionsData] = await Promise.all([
|
const [enginesData, transmissionsData] = await Promise.all([
|
||||||
vehiclesApi.getEngines(yearValue, makeValue, modelValue, trimValue),
|
vehiclesApi.getEngines(selections.year, selections.make, selections.model, selections.trimLevel),
|
||||||
vehiclesApi.getTransmissions(yearValue, makeValue, modelValue, trimValue),
|
vehiclesApi.getTransmissions(selections.year, selections.make, selections.model, selections.trimLevel),
|
||||||
]);
|
]);
|
||||||
setEngines(enginesData);
|
setEngines(enginesData);
|
||||||
setTransmissions(transmissionsData);
|
setTransmissions(transmissionsData);
|
||||||
@@ -488,24 +483,12 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set form values after options are loaded
|
// Set form values after options are loaded
|
||||||
if (decodedVehicle.year.value) {
|
if (selections.year) setValue('year', selections.year);
|
||||||
setValue('year', decodedVehicle.year.value);
|
if (selections.make) setValue('make', selections.make);
|
||||||
}
|
if (selections.model) setValue('model', selections.model);
|
||||||
if (decodedVehicle.make.value) {
|
if (selections.trimLevel) setValue('trimLevel', selections.trimLevel);
|
||||||
setValue('make', decodedVehicle.make.value);
|
if (selections.engine) setValue('engine', selections.engine);
|
||||||
}
|
if (selections.transmission) setValue('transmission', selections.transmission);
|
||||||
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 {
|
} finally {
|
||||||
setLoadingDropdowns(false);
|
setLoadingDropdowns(false);
|
||||||
isVinDecoding.current = false;
|
isVinDecoding.current = false;
|
||||||
@@ -513,17 +496,6 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
* Handle retaking VIN photo
|
||||||
* Resets and restarts capture
|
* Resets and restarts capture
|
||||||
@@ -1007,7 +979,6 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|||||||
open={!!vinOcr.result}
|
open={!!vinOcr.result}
|
||||||
result={vinOcr.result}
|
result={vinOcr.result}
|
||||||
onAccept={handleAcceptVinOcr}
|
onAccept={handleAcceptVinOcr}
|
||||||
onEdit={handleEditVinManually}
|
|
||||||
onRetake={handleRetakeVinPhoto}
|
onRetake={handleRetakeVinPhoto}
|
||||||
onClose={vinOcr.reset}
|
onClose={vinOcr.reset}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* @ai-summary Modal to review VIN OCR results and decoded vehicle data
|
* @ai-summary Modal to review VIN OCR results with editable cascade dropdowns
|
||||||
* @ai-context Shows extracted VIN with confidence, decoded fields, accept/edit/retake options
|
* @ai-context Shows extracted VIN with OCR confidence, editable vehicle dropdowns, accept/retake options
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
@@ -16,129 +16,290 @@ import {
|
|||||||
useTheme,
|
useTheme,
|
||||||
useMediaQuery,
|
useMediaQuery,
|
||||||
Drawer,
|
Drawer,
|
||||||
Divider,
|
LinearProgress,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
import WarningIcon from '@mui/icons-material/Warning';
|
import WarningIcon from '@mui/icons-material/Warning';
|
||||||
import ErrorIcon from '@mui/icons-material/Error';
|
import ErrorIcon from '@mui/icons-material/Error';
|
||||||
import CameraAltIcon from '@mui/icons-material/CameraAlt';
|
import CameraAltIcon from '@mui/icons-material/CameraAlt';
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
|
||||||
import { VinCaptureResult } from '../hooks/useVinOcr';
|
import { VinCaptureResult } from '../hooks/useVinOcr';
|
||||||
import { MatchConfidence } from '../types/vehicles.types';
|
import { VinReviewSelections } from '../types/vehicles.types';
|
||||||
|
import { vehiclesApi } from '../api/vehicles.api';
|
||||||
|
|
||||||
interface VinOcrReviewModalProps {
|
interface VinOcrReviewModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
result: VinCaptureResult | null;
|
result: VinCaptureResult | null;
|
||||||
onAccept: () => void;
|
onAccept: (selections: VinReviewSelections) => void;
|
||||||
onEdit: () => void;
|
|
||||||
onRetake: () => void;
|
onRetake: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get confidence level from percentage */
|
/** Get confidence level from OCR percentage */
|
||||||
function getConfidenceLevel(confidence: number): 'high' | 'medium' | 'low' {
|
function getConfidenceLevel(confidence: number): 'high' | 'medium' | 'low' {
|
||||||
if (confidence >= 0.9) return 'high';
|
if (confidence >= 0.9) return 'high';
|
||||||
if (confidence >= 0.7) return 'medium';
|
if (confidence >= 0.7) return 'medium';
|
||||||
return 'low';
|
return 'low';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Confidence indicator component */
|
/** VIN OCR confidence indicator with percentage */
|
||||||
const ConfidenceIndicator: React.FC<{ level: 'high' | 'medium' | 'low' | 'none' }> = ({
|
const ConfidenceIndicator: React.FC<{ level: 'high' | 'medium' | 'low'; percentage: number }> = ({
|
||||||
level,
|
level,
|
||||||
|
percentage,
|
||||||
}) => {
|
}) => {
|
||||||
const configs = {
|
const configs = {
|
||||||
high: { color: 'success.main', icon: CheckCircleIcon, label: 'High' },
|
high: { color: 'success.main', icon: CheckCircleIcon, label: 'High' },
|
||||||
medium: { color: 'warning.main', icon: WarningIcon, label: 'Medium' },
|
medium: { color: 'warning.main', icon: WarningIcon, label: 'Medium' },
|
||||||
low: { color: 'error.light', icon: ErrorIcon, label: 'Low' },
|
low: { color: 'error.light', icon: ErrorIcon, label: 'Low' },
|
||||||
none: { color: 'text.disabled', icon: ErrorIcon, label: 'N/A' },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = configs[level];
|
const config = configs[level];
|
||||||
const Icon = config.icon;
|
const Icon = config.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon sx={{ fontSize: 16, color: config.color }} />
|
<Icon sx={{ fontSize: 16, color: config.color }} />
|
||||||
<Typography variant="caption" sx={{ color: config.color }}>
|
<Typography variant="caption" sx={{ color: config.color }}>
|
||||||
{config.label}
|
{config.label} ({Math.round(percentage * 100)}%)
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Map match confidence to display level */
|
/** Shared select classes matching VehicleForm styling */
|
||||||
function matchConfidenceToLevel(confidence: MatchConfidence): 'high' | 'medium' | 'low' | 'none' {
|
const selectClasses =
|
||||||
switch (confidence) {
|
'w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi';
|
||||||
case 'high':
|
|
||||||
return 'high';
|
|
||||||
case 'medium':
|
|
||||||
return 'medium';
|
|
||||||
case 'none':
|
|
||||||
return 'none';
|
|
||||||
default:
|
|
||||||
return 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Decoded field row component */
|
/** Main modal content with cascade dropdowns */
|
||||||
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<{
|
const ReviewContent: React.FC<{
|
||||||
result: VinCaptureResult;
|
result: VinCaptureResult;
|
||||||
onAccept: () => void;
|
onAccept: (selections: VinReviewSelections) => void;
|
||||||
onEdit: () => void;
|
|
||||||
onRetake: () => void;
|
onRetake: () => void;
|
||||||
}> = ({ result, onAccept, onEdit, onRetake }) => {
|
}> = ({ result, onAccept, onRetake }) => {
|
||||||
const { ocrResult, decodedVehicle, decodeError } = result;
|
const { ocrResult, decodedVehicle, decodeError } = result;
|
||||||
const vinConfidenceLevel = getConfidenceLevel(ocrResult.confidence);
|
const vinConfidenceLevel = getConfidenceLevel(ocrResult.confidence);
|
||||||
|
|
||||||
|
// Dropdown option arrays
|
||||||
|
const [years, setYears] = useState<number[]>([]);
|
||||||
|
const [makes, setMakes] = useState<string[]>([]);
|
||||||
|
const [models, setModels] = useState<string[]>([]);
|
||||||
|
const [trims, setTrims] = useState<string[]>([]);
|
||||||
|
const [engines, setEngines] = useState<string[]>([]);
|
||||||
|
const [transmissions, setTransmissions] = useState<string[]>([]);
|
||||||
|
const [loadingDropdowns, setLoadingDropdowns] = useState(false);
|
||||||
|
|
||||||
|
// Selected values
|
||||||
|
const [selectedYear, setSelectedYear] = useState<number | undefined>(undefined);
|
||||||
|
const [selectedMake, setSelectedMake] = useState('');
|
||||||
|
const [selectedModel, setSelectedModel] = useState('');
|
||||||
|
const [selectedTrim, setSelectedTrim] = useState('');
|
||||||
|
const [selectedEngine, setSelectedEngine] = useState('');
|
||||||
|
const [selectedTransmission, setSelectedTransmission] = useState('');
|
||||||
|
|
||||||
|
// NHTSA reference values for unmatched fields
|
||||||
|
const [nhtsaRefs, setNhtsaRefs] = useState<Record<string, string | null>>({});
|
||||||
|
|
||||||
|
// Initialize dropdown options and pre-select decoded values
|
||||||
|
useEffect(() => {
|
||||||
|
const initialize = async () => {
|
||||||
|
setLoadingDropdowns(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const yearsData = await vehiclesApi.getYears();
|
||||||
|
setYears(yearsData);
|
||||||
|
|
||||||
|
if (!decodedVehicle) return;
|
||||||
|
|
||||||
|
// Store NHTSA reference values for unmatched fields
|
||||||
|
setNhtsaRefs({
|
||||||
|
make: decodedVehicle.make.confidence === 'none' ? decodedVehicle.make.nhtsaValue : null,
|
||||||
|
model: decodedVehicle.model.confidence === 'none' ? decodedVehicle.model.nhtsaValue : null,
|
||||||
|
trim: decodedVehicle.trimLevel.confidence === 'none' ? decodedVehicle.trimLevel.nhtsaValue : null,
|
||||||
|
engine: decodedVehicle.engine.confidence === 'none' ? decodedVehicle.engine.nhtsaValue : null,
|
||||||
|
transmission: decodedVehicle.transmission.confidence === 'none' ? decodedVehicle.transmission.nhtsaValue : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const yearValue = decodedVehicle.year.value;
|
||||||
|
if (!yearValue) return;
|
||||||
|
|
||||||
|
setSelectedYear(yearValue);
|
||||||
|
const makesData = await vehiclesApi.getMakes(yearValue);
|
||||||
|
setMakes(makesData);
|
||||||
|
|
||||||
|
const makeValue = decodedVehicle.make.value;
|
||||||
|
if (!makeValue) return;
|
||||||
|
|
||||||
|
setSelectedMake(makeValue);
|
||||||
|
const modelsData = await vehiclesApi.getModels(yearValue, makeValue);
|
||||||
|
setModels(modelsData);
|
||||||
|
|
||||||
|
const modelValue = decodedVehicle.model.value;
|
||||||
|
if (!modelValue) return;
|
||||||
|
|
||||||
|
setSelectedModel(modelValue);
|
||||||
|
const trimsData = await vehiclesApi.getTrims(yearValue, makeValue, modelValue);
|
||||||
|
setTrims(trimsData);
|
||||||
|
|
||||||
|
const trimValue = decodedVehicle.trimLevel.value;
|
||||||
|
if (!trimValue) return;
|
||||||
|
|
||||||
|
setSelectedTrim(trimValue);
|
||||||
|
const [enginesData, transmissionsData] = await Promise.all([
|
||||||
|
vehiclesApi.getEngines(yearValue, makeValue, modelValue, trimValue),
|
||||||
|
vehiclesApi.getTransmissions(yearValue, makeValue, modelValue, trimValue),
|
||||||
|
]);
|
||||||
|
setEngines(enginesData);
|
||||||
|
setTransmissions(transmissionsData);
|
||||||
|
|
||||||
|
if (decodedVehicle.engine.value) setSelectedEngine(decodedVehicle.engine.value);
|
||||||
|
if (decodedVehicle.transmission.value) setSelectedTransmission(decodedVehicle.transmission.value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize review modal dropdowns:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingDropdowns(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initialize();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cascade handlers
|
||||||
|
const handleYearChange = async (year: number | undefined) => {
|
||||||
|
setSelectedYear(year);
|
||||||
|
setSelectedMake('');
|
||||||
|
setSelectedModel('');
|
||||||
|
setSelectedTrim('');
|
||||||
|
setSelectedEngine('');
|
||||||
|
setSelectedTransmission('');
|
||||||
|
setModels([]);
|
||||||
|
setTrims([]);
|
||||||
|
setEngines([]);
|
||||||
|
setTransmissions([]);
|
||||||
|
|
||||||
|
if (year) {
|
||||||
|
setLoadingDropdowns(true);
|
||||||
|
try {
|
||||||
|
const makesData = await vehiclesApi.getMakes(year);
|
||||||
|
setMakes(makesData);
|
||||||
|
} catch {
|
||||||
|
setMakes([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingDropdowns(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setMakes([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMakeChange = async (make: string) => {
|
||||||
|
setSelectedMake(make);
|
||||||
|
setSelectedModel('');
|
||||||
|
setSelectedTrim('');
|
||||||
|
setSelectedEngine('');
|
||||||
|
setSelectedTransmission('');
|
||||||
|
setTrims([]);
|
||||||
|
setEngines([]);
|
||||||
|
setTransmissions([]);
|
||||||
|
|
||||||
|
if (make && selectedYear) {
|
||||||
|
setLoadingDropdowns(true);
|
||||||
|
try {
|
||||||
|
const modelsData = await vehiclesApi.getModels(selectedYear, make);
|
||||||
|
setModels(modelsData);
|
||||||
|
} catch {
|
||||||
|
setModels([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingDropdowns(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setModels([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModelChange = async (model: string) => {
|
||||||
|
setSelectedModel(model);
|
||||||
|
setSelectedTrim('');
|
||||||
|
setSelectedEngine('');
|
||||||
|
setSelectedTransmission('');
|
||||||
|
setEngines([]);
|
||||||
|
setTransmissions([]);
|
||||||
|
|
||||||
|
if (model && selectedYear && selectedMake) {
|
||||||
|
setLoadingDropdowns(true);
|
||||||
|
try {
|
||||||
|
const trimsData = await vehiclesApi.getTrims(selectedYear, selectedMake, model);
|
||||||
|
setTrims(trimsData);
|
||||||
|
} catch {
|
||||||
|
setTrims([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingDropdowns(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setTrims([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTrimChange = async (trim: string) => {
|
||||||
|
setSelectedTrim(trim);
|
||||||
|
setSelectedEngine('');
|
||||||
|
setSelectedTransmission('');
|
||||||
|
|
||||||
|
if (trim && selectedYear && selectedMake && selectedModel) {
|
||||||
|
setLoadingDropdowns(true);
|
||||||
|
try {
|
||||||
|
const [enginesData, transmissionsData] = await Promise.all([
|
||||||
|
vehiclesApi.getEngines(selectedYear, selectedMake, selectedModel, trim),
|
||||||
|
vehiclesApi.getTransmissions(selectedYear, selectedMake, selectedModel, trim),
|
||||||
|
]);
|
||||||
|
setEngines(enginesData);
|
||||||
|
setTransmissions(transmissionsData);
|
||||||
|
} catch {
|
||||||
|
setEngines([]);
|
||||||
|
setTransmissions([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingDropdowns(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setEngines([]);
|
||||||
|
setTransmissions([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAccept = () => {
|
||||||
|
onAccept({
|
||||||
|
vin: ocrResult.vin,
|
||||||
|
year: selectedYear,
|
||||||
|
make: selectedMake || undefined,
|
||||||
|
model: selectedModel || undefined,
|
||||||
|
trimLevel: selectedTrim || undefined,
|
||||||
|
engine: selectedEngine || undefined,
|
||||||
|
transmission: selectedTransmission || undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Show NHTSA reference when field had no dropdown match */
|
||||||
|
const nhtsaHint = (field: string) => {
|
||||||
|
const ref = nhtsaRefs[field];
|
||||||
|
if (!ref) return null;
|
||||||
|
// Only show hint when no value is currently selected
|
||||||
|
const selected: Record<string, string> = {
|
||||||
|
make: selectedMake,
|
||||||
|
model: selectedModel,
|
||||||
|
trim: selectedTrim,
|
||||||
|
engine: selectedEngine,
|
||||||
|
transmission: selectedTransmission,
|
||||||
|
};
|
||||||
|
if (selected[field]) return null;
|
||||||
|
return (
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-titanio">
|
||||||
|
NHTSA returned: {ref}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* VIN Section */}
|
{/* VIN Section - OCR confidence only */}
|
||||||
<Box sx={{ mb: 3 }}>
|
<Box sx={{ mb: 3 }}>
|
||||||
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||||
Detected VIN
|
Detected VIN
|
||||||
@@ -152,7 +313,12 @@ const ReviewContent: React.FC<{
|
|||||||
backgroundColor: 'action.hover',
|
backgroundColor: 'action.hover',
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
border: 1,
|
border: 1,
|
||||||
borderColor: vinConfidenceLevel === 'high' ? 'success.main' : 'warning.main',
|
borderColor:
|
||||||
|
vinConfidenceLevel === 'high'
|
||||||
|
? 'success.main'
|
||||||
|
: vinConfidenceLevel === 'medium'
|
||||||
|
? 'warning.main'
|
||||||
|
: 'error.light',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography
|
<Typography
|
||||||
@@ -163,11 +329,13 @@ const ReviewContent: React.FC<{
|
|||||||
>
|
>
|
||||||
{ocrResult.vin}
|
{ocrResult.vin}
|
||||||
</Typography>
|
</Typography>
|
||||||
<ConfidenceIndicator level={vinConfidenceLevel} />
|
<ConfidenceIndicator level={vinConfidenceLevel} percentage={ocrResult.confidence} />
|
||||||
</Box>
|
</Box>
|
||||||
{vinConfidenceLevel !== 'high' && (
|
{vinConfidenceLevel !== 'high' && (
|
||||||
<Typography variant="caption" color="warning.main" sx={{ mt: 1, display: 'block' }}>
|
<Typography variant="caption" color="warning.main" sx={{ mt: 1, display: 'block' }}>
|
||||||
Low confidence detection - please verify the VIN is correct
|
{vinConfidenceLevel === 'low'
|
||||||
|
? 'Low confidence detection - please verify the VIN is correct'
|
||||||
|
: 'Medium confidence - please verify the VIN is correct'}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -179,73 +347,197 @@ const ReviewContent: React.FC<{
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Decoded Vehicle Information */}
|
{/* Loading indicator */}
|
||||||
{decodedVehicle && (
|
{loadingDropdowns && <LinearProgress sx={{ mb: 2 }} />}
|
||||||
<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' }}>
|
{/* Vehicle Details - Editable Cascade Dropdowns */}
|
||||||
Fields with lower confidence may need manual verification.
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||||
|
Vehicle Details
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
|
||||||
|
Review and adjust the vehicle details below.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{/* Year */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
|
||||||
|
Year
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedYear || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleYearChange(e.target.value ? parseInt(e.target.value) : undefined)
|
||||||
|
}
|
||||||
|
className={selectClasses}
|
||||||
|
style={{ fontSize: '16px' }}
|
||||||
|
>
|
||||||
|
<option value="">Select Year</option>
|
||||||
|
{years.map((year) => (
|
||||||
|
<option key={year} value={year}>
|
||||||
|
{year}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Make */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
|
||||||
|
Make
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedMake}
|
||||||
|
onChange={(e) => handleMakeChange(e.target.value)}
|
||||||
|
className={selectClasses}
|
||||||
|
style={{ fontSize: '16px' }}
|
||||||
|
disabled={loadingDropdowns || !selectedYear || makes.length === 0}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{loadingDropdowns
|
||||||
|
? 'Loading...'
|
||||||
|
: !selectedYear
|
||||||
|
? 'Select year first'
|
||||||
|
: makes.length === 0
|
||||||
|
? 'No makes available'
|
||||||
|
: 'Select Make'}
|
||||||
|
</option>
|
||||||
|
{makes.map((make) => (
|
||||||
|
<option key={make} value={make}>
|
||||||
|
{make}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{nhtsaHint('make')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
|
||||||
|
Model
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedModel}
|
||||||
|
onChange={(e) => handleModelChange(e.target.value)}
|
||||||
|
className={selectClasses}
|
||||||
|
style={{ fontSize: '16px' }}
|
||||||
|
disabled={loadingDropdowns || !selectedMake || models.length === 0}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{loadingDropdowns
|
||||||
|
? 'Loading...'
|
||||||
|
: !selectedMake
|
||||||
|
? 'Select make first'
|
||||||
|
: models.length === 0
|
||||||
|
? 'No models available'
|
||||||
|
: 'Select Model'}
|
||||||
|
</option>
|
||||||
|
{models.map((model) => (
|
||||||
|
<option key={model} value={model}>
|
||||||
|
{model}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{nhtsaHint('model')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trim */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
|
||||||
|
Trim
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedTrim}
|
||||||
|
onChange={(e) => handleTrimChange(e.target.value)}
|
||||||
|
className={selectClasses}
|
||||||
|
style={{ fontSize: '16px' }}
|
||||||
|
disabled={loadingDropdowns || !selectedModel || trims.length === 0}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{loadingDropdowns
|
||||||
|
? 'Loading...'
|
||||||
|
: !selectedModel
|
||||||
|
? 'Select model first'
|
||||||
|
: trims.length === 0
|
||||||
|
? 'No trims available'
|
||||||
|
: 'Select Trim'}
|
||||||
|
</option>
|
||||||
|
{trims.map((trim) => (
|
||||||
|
<option key={trim} value={trim}>
|
||||||
|
{trim}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{nhtsaHint('trim')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Engine */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
|
||||||
|
Engine
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedEngine}
|
||||||
|
onChange={(e) => setSelectedEngine(e.target.value)}
|
||||||
|
className={selectClasses}
|
||||||
|
style={{ fontSize: '16px' }}
|
||||||
|
disabled={loadingDropdowns || !selectedTrim || engines.length === 0}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{loadingDropdowns
|
||||||
|
? 'Loading...'
|
||||||
|
: !selectedTrim
|
||||||
|
? 'Select trim first'
|
||||||
|
: engines.length === 0
|
||||||
|
? 'N/A (Electric)'
|
||||||
|
: 'Select Engine'}
|
||||||
|
</option>
|
||||||
|
{engines.map((engine) => (
|
||||||
|
<option key={engine} value={engine}>
|
||||||
|
{engine}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{nhtsaHint('engine')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transmission */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
|
||||||
|
Transmission
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedTransmission}
|
||||||
|
onChange={(e) => setSelectedTransmission(e.target.value)}
|
||||||
|
className={selectClasses}
|
||||||
|
style={{ fontSize: '16px' }}
|
||||||
|
disabled={loadingDropdowns || !selectedTrim || transmissions.length === 0}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{loadingDropdowns
|
||||||
|
? 'Loading...'
|
||||||
|
: !selectedTrim
|
||||||
|
? 'Select trim first'
|
||||||
|
: transmissions.length === 0
|
||||||
|
? 'No transmissions available'
|
||||||
|
: 'Select Transmission'}
|
||||||
|
</option>
|
||||||
|
{transmissions.map((transmission) => (
|
||||||
|
<option key={transmission} value={transmission}>
|
||||||
|
{transmission}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{nhtsaHint('transmission')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* No decoded data - VIN only mode */}
|
{/* No decoded data - VIN only mode */}
|
||||||
{!decodedVehicle && !decodeError && (
|
{!decodedVehicle && !decodeError && (
|
||||||
<Alert severity="info">
|
<Alert severity="info" sx={{ mb: 2 }}>
|
||||||
VIN extracted successfully. Vehicle details will need to be entered manually.
|
VIN extracted successfully. Select vehicle details above or enter them after accepting.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -267,20 +559,12 @@ const ReviewContent: React.FC<{
|
|||||||
>
|
>
|
||||||
Retake Photo
|
Retake Photo
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<EditIcon />}
|
|
||||||
onClick={onEdit}
|
|
||||||
fullWidth
|
|
||||||
sx={{ minHeight: 44 }}
|
|
||||||
>
|
|
||||||
Edit Manually
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
startIcon={<CheckCircleIcon />}
|
startIcon={<CheckCircleIcon />}
|
||||||
onClick={onAccept}
|
onClick={handleAccept}
|
||||||
fullWidth
|
fullWidth
|
||||||
|
disabled={loadingDropdowns}
|
||||||
sx={{ minHeight: 44 }}
|
sx={{ minHeight: 44 }}
|
||||||
>
|
>
|
||||||
Accept
|
Accept
|
||||||
@@ -294,7 +578,6 @@ export const VinOcrReviewModal: React.FC<VinOcrReviewModalProps> = ({
|
|||||||
open,
|
open,
|
||||||
result,
|
result,
|
||||||
onAccept,
|
onAccept,
|
||||||
onEdit,
|
|
||||||
onRetake,
|
onRetake,
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -318,7 +601,7 @@ export const VinOcrReviewModal: React.FC<VinOcrReviewModalProps> = ({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ p: 2 }}>
|
<Box sx={{ p: 2, overflowY: 'auto' }}>
|
||||||
{/* Drag handle */}
|
{/* Drag handle */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -333,12 +616,7 @@ export const VinOcrReviewModal: React.FC<VinOcrReviewModalProps> = ({
|
|||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
VIN Detected
|
VIN Detected
|
||||||
</Typography>
|
</Typography>
|
||||||
<ReviewContent
|
<ReviewContent result={result} onAccept={onAccept} onRetake={onRetake} />
|
||||||
result={result}
|
|
||||||
onAccept={onAccept}
|
|
||||||
onEdit={onEdit}
|
|
||||||
onRetake={onRetake}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
@@ -357,12 +635,7 @@ export const VinOcrReviewModal: React.FC<VinOcrReviewModalProps> = ({
|
|||||||
>
|
>
|
||||||
<DialogTitle>VIN Detected</DialogTitle>
|
<DialogTitle>VIN Detected</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<ReviewContent
|
<ReviewContent result={result} onAccept={onAccept} onRetake={onRetake} />
|
||||||
result={result}
|
|
||||||
onAccept={onAccept}
|
|
||||||
onEdit={onEdit}
|
|
||||||
onRetake={onRetake}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions sx={{ display: 'none' }}>
|
<DialogActions sx={{ display: 'none' }}>
|
||||||
{/* Actions are in ReviewContent */}
|
{/* Actions are in ReviewContent */}
|
||||||
|
|||||||
@@ -91,3 +91,16 @@ export interface DecodedVehicleData {
|
|||||||
engine: MatchedField<string>;
|
engine: MatchedField<string>;
|
||||||
transmission: MatchedField<string>;
|
transmission: MatchedField<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selections made by user in VIN OCR review modal
|
||||||
|
*/
|
||||||
|
export interface VinReviewSelections {
|
||||||
|
vin: string;
|
||||||
|
year?: number;
|
||||||
|
make?: string;
|
||||||
|
model?: string;
|
||||||
|
trimLevel?: string;
|
||||||
|
engine?: string;
|
||||||
|
transmission?: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user