Updates to database and API for dropdowns.

This commit is contained in:
Eric Gullickson
2025-11-11 10:29:02 -06:00
parent 3dc0f2a733
commit 8376aee7ed
157 changed files with 2573659 additions and 1548221 deletions

View File

@@ -3,7 +3,7 @@
*/
import { apiClient } from '../../../core/api/client';
import { Vehicle, CreateVehicleRequest, UpdateVehicleRequest, DropdownOption, VINDecodeResponse } from '../types/vehicles.types';
import { Vehicle, CreateVehicleRequest, UpdateVehicleRequest, VINDecodeResponse } from '../types/vehicles.types';
// All requests (including dropdowns) use authenticated apiClient
@@ -38,28 +38,28 @@ export const vehiclesApi = {
return response.data;
},
getMakes: async (year: number): Promise<DropdownOption[]> => {
getMakes: async (year: number): Promise<string[]> => {
const response = await apiClient.get(`/vehicles/dropdown/makes?year=${year}`);
return response.data;
},
getModels: async (year: number, makeId: number): Promise<DropdownOption[]> => {
const response = await apiClient.get(`/vehicles/dropdown/models?year=${year}&make_id=${makeId}`);
getModels: async (year: number, make: string): Promise<string[]> => {
const response = await apiClient.get(`/vehicles/dropdown/models?year=${year}&make=${encodeURIComponent(make)}`);
return response.data;
},
getTransmissions: async (year: number, makeId: number, modelId: number): Promise<DropdownOption[]> => {
const response = await apiClient.get(`/vehicles/dropdown/transmissions?year=${year}&make_id=${makeId}&model_id=${modelId}`);
getTransmissions: async (year: number, make: string, model: string): Promise<string[]> => {
const response = await apiClient.get(`/vehicles/dropdown/transmissions?year=${year}&make=${encodeURIComponent(make)}&model=${encodeURIComponent(model)}`);
return response.data;
},
getEngines: async (year: number, makeId: number, modelId: number, trimId: number): Promise<DropdownOption[]> => {
const response = await apiClient.get(`/vehicles/dropdown/engines?year=${year}&make_id=${makeId}&model_id=${modelId}&trim_id=${trimId}`);
getEngines: async (year: number, make: string, model: string, trim: string): Promise<string[]> => {
const response = await apiClient.get(`/vehicles/dropdown/engines?year=${year}&make=${encodeURIComponent(make)}&model=${encodeURIComponent(model)}&trim=${encodeURIComponent(trim)}`);
return response.data;
},
getTrims: async (year: number, makeId: number, modelId: number): Promise<DropdownOption[]> => {
const response = await apiClient.get(`/vehicles/dropdown/trims?year=${year}&make_id=${makeId}&model_id=${modelId}`);
getTrims: async (year: number, make: string, model: string): Promise<string[]> => {
const response = await apiClient.get(`/vehicles/dropdown/trims?year=${year}&make=${encodeURIComponent(make)}&model=${encodeURIComponent(model)}`);
return response.data;
},

View File

@@ -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, DropdownOption } from '../types/vehicles.types';
import { CreateVehicleRequest } from '../types/vehicles.types';
import { vehiclesApi } from '../api/vehicles.api';
const vehicleSchema = z
@@ -68,21 +68,20 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
loading,
}) => {
const [years, setYears] = useState<number[]>([]);
const [makes, setMakes] = useState<DropdownOption[]>([]);
const [models, setModels] = useState<DropdownOption[]>([]);
const [engines, setEngines] = useState<DropdownOption[]>([]);
const [trims, setTrims] = useState<DropdownOption[]>([]);
const [transmissions, setTransmissions] = useState<DropdownOption[]>([]);
const [makes, setMakes] = useState<string[]>([]);
const [models, setModels] = useState<string[]>([]);
const [engines, setEngines] = useState<string[]>([]);
const [trims, setTrims] = useState<string[]>([]);
const [transmissions, setTransmissions] = useState<string[]>([]);
const [selectedYear, setSelectedYear] = useState<number | undefined>();
const [selectedMake, setSelectedMake] = useState<DropdownOption | undefined>();
const [selectedModel, setSelectedModel] = useState<DropdownOption | undefined>();
const [selectedMake, setSelectedMake] = useState<string>('');
const [selectedModel, setSelectedModel] = useState<string>('');
const [selectedTrim, setSelectedTrim] = useState<string>('');
const [loadingDropdowns, setLoadingDropdowns] = useState(false);
const [selectedTrim, setSelectedTrim] = useState<DropdownOption | undefined>();
const [decodingVIN, setDecodingVIN] = useState(false);
const [decodeSuccess, setDecodeSuccess] = useState(false);
const hasInitialized = useRef(false);
const isInitializing = useRef(false);
const [dropdownsReady, setDropdownsReady] = useState(false);
const {
register,
@@ -157,57 +156,51 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
try {
setLoadingDropdowns(true);
// Set year and load makes
// Step 1: Set year and load makes
setSelectedYear(initialData.year);
const makesData = await vehiclesApi.getMakes(initialData.year);
setMakes(makesData);
if (initialData.make) {
const makeOption = makesData.find(m => m.name === initialData.make);
if (makeOption) {
setSelectedMake(makeOption);
// Load models
const modelsData = await vehiclesApi.getModels(initialData.year, makeOption.id);
setModels(modelsData);
if (initialData.model) {
const modelOption = modelsData.find(m => m.name === initialData.model);
if (modelOption) {
setSelectedModel(modelOption);
// Load trims and transmissions in parallel
const [trimsData, transmissionsData] = await Promise.all([
vehiclesApi.getTrims(initialData.year, makeOption.id, modelOption.id),
vehiclesApi.getTransmissions(initialData.year, makeOption.id, modelOption.id)
]);
setTrims(trimsData);
setTransmissions(transmissionsData);
if (initialData.trimLevel) {
const trimOption = trimsData.find(t => t.name === initialData.trimLevel);
if (trimOption) {
setSelectedTrim(trimOption);
// Load engines
const enginesData = await vehiclesApi.getEngines(
initialData.year,
makeOption.id,
modelOption.id,
trimOption.id
);
setEngines(enginesData);
}
}
}
}
}
if (!initialData.make) {
isInitializing.current = false;
return;
}
// Signal that dropdowns are ready
setDropdownsReady(true);
// Step 2: Set make and load models
setSelectedMake(initialData.make);
const modelsData = await vehiclesApi.getModels(initialData.year, initialData.make);
setModels(modelsData);
if (!initialData.model) {
isInitializing.current = false;
return;
}
// Step 3: Set model and load trims + transmissions
setSelectedModel(initialData.model);
const [trimsData, transmissionsData] = await Promise.all([
vehiclesApi.getTrims(initialData.year, initialData.make, initialData.model),
vehiclesApi.getTransmissions(initialData.year, initialData.make, initialData.model)
]);
setTrims(trimsData);
setTransmissions(transmissionsData);
if (initialData.trimLevel) {
// Step 4: Set trim and load engines
setSelectedTrim(initialData.trimLevel);
const enginesData = await vehiclesApi.getEngines(
initialData.year,
initialData.make,
initialData.model,
initialData.trimLevel
);
setEngines(enginesData);
}
isInitializing.current = false;
} catch (error) {
console.error('Failed to initialize edit mode:', error);
isInitializing.current = false;
} finally {
setLoadingDropdowns(false);
}
@@ -216,37 +209,12 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
initializeEditMode();
}, [initialData]); // Run when initialData is available
// Reset form values after dropdowns are loaded and rendered
// Reset form values after initialization
useEffect(() => {
if (!dropdownsReady || !initialData) return;
let timer2: NodeJS.Timeout;
// Use setTimeout to ensure React has rendered the dropdown options
const timer1 = setTimeout(() => {
// Normalize the data to match dropdown option values (lowercase)
const normalizedData = {
...initialData,
make: initialData.make?.toLowerCase(),
model: initialData.model?.toLowerCase(),
trimLevel: initialData.trimLevel,
transmission: initialData.transmission,
engine: initialData.engine
};
reset(normalizedData);
// Mark initialization complete after a delay to allow effects to process
timer2 = setTimeout(() => {
isInitializing.current = false;
}, 100);
}, 50);
return () => {
clearTimeout(timer1);
if (timer2) clearTimeout(timer2);
};
}, [dropdownsReady, initialData, reset]);
if (!isInitializing.current && initialData) {
reset(initialData);
}
}, [initialData, reset]);
// Load makes when year changes
useEffect(() => {
@@ -262,17 +230,18 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
setSelectedYear(watchedYear);
// Clear dependent selections
setSelectedMake('');
setSelectedModel('');
setSelectedTrim('');
setModels([]);
setEngines([]);
setTrims([]);
setEngines([]);
setTransmissions([]);
setSelectedMake(undefined);
setSelectedModel(undefined);
setValue('make', '');
setValue('model', '');
setValue('trimLevel', '');
setValue('transmission', '');
setValue('engine', '');
setValue('trimLevel', '');
} catch (error) {
console.error('Failed to load makes:', error);
setMakes([]);
@@ -290,76 +259,70 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
// Skip during initialization
if (isInitializing.current) return;
if (watchedMake && watchedYear && watchedMake !== selectedMake?.name) {
const makeOption = makes.find(make => make.name === watchedMake);
if (makeOption) {
const loadModels = async () => {
setLoadingDropdowns(true);
try {
const modelsData = await vehiclesApi.getModels(watchedYear, makeOption.id);
setModels(modelsData);
setSelectedMake(makeOption);
if (watchedMake && watchedYear && watchedMake !== selectedMake) {
const loadModels = async () => {
setLoadingDropdowns(true);
try {
const modelsData = await vehiclesApi.getModels(watchedYear, watchedMake);
setModels(modelsData);
setSelectedMake(watchedMake);
// Clear dependent selections
setEngines([]);
setTrims([]);
setTransmissions([]);
setSelectedModel(undefined);
setValue('model', '');
setValue('transmission', '');
setValue('engine', '');
setValue('trimLevel', '');
} catch (error) {
console.error('Failed to load models:', error);
setModels([]);
} finally {
setLoadingDropdowns(false);
}
};
// Clear dependent selections
setSelectedModel('');
setSelectedTrim('');
setTrims([]);
setEngines([]);
setTransmissions([]);
setValue('model', '');
setValue('trimLevel', '');
setValue('transmission', '');
setValue('engine', '');
} catch (error) {
console.error('Failed to load models:', error);
setModels([]);
} finally {
setLoadingDropdowns(false);
}
};
loadModels();
}
loadModels();
}
}, [watchedMake, watchedYear, selectedMake, makes, setValue]);
}, [watchedMake, watchedYear, selectedMake, setValue]);
// Load trims when model changes
// Load trims and transmissions when model changes
useEffect(() => {
// Skip during initialization
if (isInitializing.current) return;
if (watchedModel && watchedYear && selectedMake && watchedModel !== selectedModel?.name) {
const modelOption = models.find(model => model.name === watchedModel);
if (modelOption) {
const loadTrims = async () => {
setLoadingDropdowns(true);
try {
const [trimsData, transmissionsData] = await Promise.all([
vehiclesApi.getTrims(watchedYear, selectedMake.id, modelOption.id),
vehiclesApi.getTransmissions(watchedYear, selectedMake.id, modelOption.id)
]);
setTrims(trimsData);
setTransmissions(transmissionsData);
setSelectedModel(modelOption);
// Clear deeper selections
setEngines([]);
setSelectedTrim(undefined);
setValue('transmission', '');
setValue('engine', '');
setValue('trimLevel', '');
} catch (error) {
console.error('Failed to load detailed data:', error);
setTrims([]);
setEngines([]);
setTransmissions([]);
} finally {
setLoadingDropdowns(false);
}
};
if (watchedModel && watchedYear && selectedMake && watchedModel !== selectedModel) {
const loadTrimsAndTransmissions = async () => {
setLoadingDropdowns(true);
try {
const [trimsData, transmissionsData] = await Promise.all([
vehiclesApi.getTrims(watchedYear, selectedMake, watchedModel),
vehiclesApi.getTransmissions(watchedYear, selectedMake, watchedModel)
]);
setTrims(trimsData);
setTransmissions(transmissionsData);
setSelectedModel(watchedModel);
loadTrims();
}
// Clear deeper selections
setSelectedTrim('');
setEngines([]);
setValue('trimLevel', '');
setValue('engine', '');
} catch (error) {
console.error('Failed to load trims and transmissions:', error);
setTrims([]);
setTransmissions([]);
} finally {
setLoadingDropdowns(false);
}
};
loadTrimsAndTransmissions();
}
}, [watchedModel, watchedYear, selectedMake, selectedModel, models, setValue]);
}, [watchedModel, watchedYear, selectedMake, selectedModel, setValue]);
// Load engines when trim changes
useEffect(() => {
@@ -368,25 +331,28 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
const trimName = watch('trimLevel');
if (trimName && watchedYear && selectedMake && selectedModel) {
const trimOption = trims.find(t => t.name === trimName);
if (trimOption) {
const loadEngines = async () => {
setLoadingDropdowns(true);
try {
const enginesData = await vehiclesApi.getEngines(watchedYear, selectedMake.id, selectedModel.id, trimOption.id);
setEngines(enginesData);
setSelectedTrim(trimOption);
} catch (error) {
console.error('Failed to load engines:', error);
setEngines([]);
} finally {
setLoadingDropdowns(false);
}
};
loadEngines();
}
const loadEngines = async () => {
setLoadingDropdowns(true);
try {
const enginesData = await vehiclesApi.getEngines(
watchedYear,
selectedMake,
selectedModel,
trimName
);
setEngines(enginesData);
setSelectedTrim(trimName);
} catch (error) {
console.error('Failed to load engines:', error);
setEngines([]);
} finally {
setLoadingDropdowns(false);
}
};
loadEngines();
}
}, [trims, selectedMake, selectedModel, watchedYear, setValue, watch('trimLevel')]);
}, [watchedYear, selectedMake, selectedModel, watch('trimLevel')]);
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
@@ -431,6 +397,11 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
</label>
<select
{...register('year', { valueAsNumber: true })}
value={selectedYear || ''}
onChange={(e) => {
const year = parseInt(e.target.value);
setValue('year', year);
}}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-[44px]"
style={{ fontSize: '16px' }}
>
@@ -449,14 +420,27 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
</label>
<select
{...register('make')}
value={selectedMake}
onChange={(e) => {
const make = e.target.value;
setValue('make', make);
}}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-[44px]"
disabled={loadingDropdowns || !watchedYear}
disabled={loadingDropdowns || !watchedYear || makes.length === 0}
style={{ fontSize: '16px' }}
>
<option value="">Select Make</option>
<option value="">
{loadingDropdowns
? 'Loading...'
: !watchedYear
? 'Select year first'
: makes.length === 0
? 'No makes available'
: 'Select Make'}
</option>
{makes.map((make) => (
<option key={make.id} value={make.name}>
{make.name}
<option key={make} value={make}>
{make}
</option>
))}
</select>
@@ -468,14 +452,27 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
</label>
<select
{...register('model')}
value={selectedModel}
onChange={(e) => {
const model = e.target.value;
setValue('model', model);
}}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-[44px]"
disabled={loadingDropdowns || !watchedMake || models.length === 0}
style={{ fontSize: '16px' }}
>
<option value="">Select Model</option>
<option value="">
{loadingDropdowns
? 'Loading...'
: !watchedMake
? 'Select make first'
: models.length === 0
? 'No models available'
: 'Select Model'}
</option>
{models.map((model) => (
<option key={model.id} value={model.name}>
{model.name}
<option key={model} value={model}>
{model}
</option>
))}
</select>
@@ -490,14 +487,27 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
</label>
<select
{...register('trimLevel')}
value={selectedTrim}
onChange={(e) => {
const trim = e.target.value;
setValue('trimLevel', trim);
}}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-[44px]"
disabled={loadingDropdowns || !watchedModel || trims.length === 0}
style={{ fontSize: '16px' }}
>
<option value="">Select Trim</option>
<option value="">
{loadingDropdowns
? 'Loading...'
: !watchedModel
? 'Select model first'
: trims.length === 0
? 'No trims available'
: 'Select Trim'}
</option>
{trims.map((trim) => (
<option key={trim.id} value={trim.name}>
{trim.name}
<option key={trim} value={trim}>
{trim}
</option>
))}
</select>
@@ -511,13 +521,21 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
<select
{...register('engine')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-[44px]"
disabled={loadingDropdowns || !watchedModel || !selectedTrim || engines.length === 0}
disabled={loadingDropdowns || !selectedTrim || engines.length === 0}
style={{ fontSize: '16px' }}
>
<option value="">Select Engine</option>
<option value="">
{loadingDropdowns
? 'Loading...'
: !selectedTrim
? 'Select trim first'
: engines.length === 0
? 'N/A (Electric)'
: 'Select Engine'}
</option>
{engines.map((engine) => (
<option key={engine.id} value={engine.name}>
{engine.name}
<option key={engine} value={engine}>
{engine}
</option>
))}
</select>
@@ -531,13 +549,21 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
<select
{...register('transmission')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-[44px]"
disabled={loadingDropdowns || transmissions.length === 0}
disabled={loadingDropdowns || !watchedModel || transmissions.length === 0}
style={{ fontSize: '16px' }}
>
<option value="">Select Transmission</option>
<option value="">
{loadingDropdowns
? 'Loading...'
: !watchedModel
? 'Select model first'
: transmissions.length === 0
? 'No transmissions available'
: 'Select Transmission'}
</option>
{transmissions.map((transmission) => (
<option key={transmission.id} value={transmission.name}>
{transmission.name}
<option key={transmission} value={transmission}>
{transmission}
</option>
))}
</select>

View File

@@ -53,11 +53,6 @@ export interface UpdateVehicleRequest {
odometerReading?: number;
}
export interface DropdownOption {
id: number;
name: string;
}
export interface VINDecodeResponse {
vin: string;
success: boolean;