From e9020dbb2f5c1dd3aa6f8422e0d6faa7df671eb3 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 8 Feb 2026 19:24:27 -0600 Subject: [PATCH] feat: improve VIN confidence reporting and editable review dropdowns (refs #125) 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 --- .../vehicles/components/VehicleForm.tsx | 93 +-- .../vehicles/components/VinOcrReviewModal.tsx | 609 +++++++++++++----- .../features/vehicles/types/vehicles.types.ts | 13 + 3 files changed, 486 insertions(+), 229 deletions(-) diff --git a/frontend/src/features/vehicles/components/VehicleForm.tsx b/frontend/src/features/vehicles/components/VehicleForm.tsx index 6420210..cba6faa 100644 --- a/frontend/src/features/vehicles/components/VehicleForm.tsx +++ b/frontend/src/features/vehicles/components/VehicleForm.tsx @@ -7,7 +7,7 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; 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 { VehicleImageUpload } from './VehicleImageUpload'; import { useTierAccess } from '../../../core/hooks/useTierAccess'; @@ -433,52 +433,47 @@ export const VehicleForm: React.FC = ({ const watchedVin = watch('vin'); /** - * Handle accepting VIN OCR result - * Populates VIN and decoded fields into the form + * Handle accepting VIN OCR result with user-edited selections from review modal + * Populates VIN and selected dropdown values into the form */ - const handleAcceptVinOcr = async () => { - const result = vinOcr.acceptResult(); - if (!result) return; - - const { ocrResult, decodedVehicle } = result; + const handleAcceptVinOcr = async (selections: VinReviewSelections) => { + // Clear the OCR result state + vinOcr.acceptResult(); // Set the VIN immediately - setValue('vin', ocrResult.vin); + setValue('vin', selections.vin); - // If we have decoded vehicle data, populate the form similar to handleDecodeVin - if (decodedVehicle) { + // Populate form with user's dropdown selections + const hasSelections = selections.year || selections.make || selections.model || + selections.trimLevel || selections.engine || selections.transmission; + + if (hasSelections) { // 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); + // Load dropdown options hierarchically for the selected values + if (selections.year) { + prevYear.current = selections.year; + const makesData = await vehiclesApi.getMakes(selections.year); setMakes(makesData); - if (makeValue) { - prevMake.current = makeValue; - const modelsData = await vehiclesApi.getModels(yearValue, makeValue); + if (selections.make) { + prevMake.current = selections.make; + const modelsData = await vehiclesApi.getModels(selections.year, selections.make); setModels(modelsData); - if (modelValue) { - prevModel.current = modelValue; - const trimsData = await vehiclesApi.getTrims(yearValue, makeValue, modelValue); + if (selections.model) { + prevModel.current = selections.model; + const trimsData = await vehiclesApi.getTrims(selections.year, selections.make, selections.model); setTrims(trimsData); - if (trimValue) { - prevTrim.current = trimValue; + if (selections.trimLevel) { + prevTrim.current = selections.trimLevel; const [enginesData, transmissionsData] = await Promise.all([ - vehiclesApi.getEngines(yearValue, makeValue, modelValue, trimValue), - vehiclesApi.getTransmissions(yearValue, makeValue, modelValue, trimValue), + vehiclesApi.getEngines(selections.year, selections.make, selections.model, selections.trimLevel), + vehiclesApi.getTransmissions(selections.year, selections.make, selections.model, selections.trimLevel), ]); setEngines(enginesData); setTransmissions(transmissionsData); @@ -488,24 +483,12 @@ export const VehicleForm: React.FC = ({ } // 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); - } + if (selections.year) setValue('year', selections.year); + if (selections.make) setValue('make', selections.make); + if (selections.model) setValue('model', selections.model); + if (selections.trimLevel) setValue('trimLevel', selections.trimLevel); + if (selections.engine) setValue('engine', selections.engine); + if (selections.transmission) setValue('transmission', selections.transmission); } finally { setLoadingDropdowns(false); isVinDecoding.current = false; @@ -513,17 +496,6 @@ export const VehicleForm: React.FC = ({ } }; - /** - * 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 @@ -1007,7 +979,6 @@ export const VehicleForm: React.FC = ({ open={!!vinOcr.result} result={vinOcr.result} onAccept={handleAcceptVinOcr} - onEdit={handleEditVinManually} onRetake={handleRetakeVinPhoto} onClose={vinOcr.reset} /> diff --git a/frontend/src/features/vehicles/components/VinOcrReviewModal.tsx b/frontend/src/features/vehicles/components/VinOcrReviewModal.tsx index 47ff6ce..acc05b3 100644 --- a/frontend/src/features/vehicles/components/VinOcrReviewModal.tsx +++ b/frontend/src/features/vehicles/components/VinOcrReviewModal.tsx @@ -1,9 +1,9 @@ /** - * @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 + * @ai-summary Modal to review VIN OCR results with editable cascade dropdowns + * @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 { Dialog, DialogTitle, @@ -16,129 +16,290 @@ import { useTheme, useMediaQuery, Drawer, - Divider, + LinearProgress, } 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'; +import { VinReviewSelections } from '../types/vehicles.types'; +import { vehiclesApi } from '../api/vehicles.api'; interface VinOcrReviewModalProps { open: boolean; result: VinCaptureResult | null; - onAccept: () => void; - onEdit: () => void; + onAccept: (selections: VinReviewSelections) => void; onRetake: () => void; onClose: () => void; } -/** Get confidence level from percentage */ +/** Get confidence level from OCR 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' }> = ({ +/** VIN OCR confidence indicator with percentage */ +const ConfidenceIndicator: React.FC<{ level: 'high' | 'medium' | 'low'; percentage: number }> = ({ level, + percentage, }) => { 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 ( - + - {config.label} + {config.label} ({Math.round(percentage * 100)}%) ); }; -/** 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'; - } -} +/** Shared select classes matching VehicleForm styling */ +const selectClasses = + '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'; -/** 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 ( - - - - {label} - - - {displayValue} - - {nhtsaValue && value !== nhtsaValue && ( - - NHTSA: {nhtsaValue} - - )} - - - - ); -}; - -/** Main modal content */ +/** Main modal content with cascade dropdowns */ const ReviewContent: React.FC<{ result: VinCaptureResult; - onAccept: () => void; - onEdit: () => void; + onAccept: (selections: VinReviewSelections) => void; onRetake: () => void; -}> = ({ result, onAccept, onEdit, onRetake }) => { +}> = ({ result, onAccept, onRetake }) => { const { ocrResult, decodedVehicle, decodeError } = result; const vinConfidenceLevel = getConfidenceLevel(ocrResult.confidence); + // Dropdown option arrays + const [years, setYears] = useState([]); + const [makes, setMakes] = useState([]); + const [models, setModels] = useState([]); + const [trims, setTrims] = useState([]); + const [engines, setEngines] = useState([]); + const [transmissions, setTransmissions] = useState([]); + const [loadingDropdowns, setLoadingDropdowns] = useState(false); + + // Selected values + const [selectedYear, setSelectedYear] = useState(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>({}); + + // 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 = { + make: selectedMake, + model: selectedModel, + trim: selectedTrim, + engine: selectedEngine, + transmission: selectedTransmission, + }; + if (selected[field]) return null; + return ( +

+ NHTSA returned: {ref} +

+ ); + }; + return ( <> - {/* VIN Section */} + {/* VIN Section - OCR confidence only */} Detected VIN @@ -152,7 +313,12 @@ const ReviewContent: React.FC<{ backgroundColor: 'action.hover', borderRadius: 1, border: 1, - borderColor: vinConfidenceLevel === 'high' ? 'success.main' : 'warning.main', + borderColor: + vinConfidenceLevel === 'high' + ? 'success.main' + : vinConfidenceLevel === 'medium' + ? 'warning.main' + : 'error.light', }} > {ocrResult.vin} - + {vinConfidenceLevel !== 'high' && ( - 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'} )}
@@ -179,73 +347,197 @@ const ReviewContent: React.FC<{ )} - {/* Decoded Vehicle Information */} - {decodedVehicle && ( - - - Decoded Vehicle Information - - - - - - - - - - - - - - + {/* Loading indicator */} + {loadingDropdowns && } - - Fields with lower confidence may need manual verification. - - - )} + {/* Vehicle Details - Editable Cascade Dropdowns */} + + + Vehicle Details + + + Review and adjust the vehicle details below. + + +
+ {/* Year */} +
+ + +
+ + {/* Make */} +
+ + + {nhtsaHint('make')} +
+ + {/* Model */} +
+ + + {nhtsaHint('model')} +
+ + {/* Trim */} +
+ + + {nhtsaHint('trim')} +
+ + {/* Engine */} +
+ + + {nhtsaHint('engine')} +
+ + {/* Transmission */} +
+ + + {nhtsaHint('transmission')} +
+
+
{/* No decoded data - VIN only mode */} {!decodedVehicle && !decodeError && ( - - VIN extracted successfully. Vehicle details will need to be entered manually. + + VIN extracted successfully. Select vehicle details above or enter them after accepting. )} @@ -267,20 +559,12 @@ const ReviewContent: React.FC<{ > Retake Photo -