From 6683f1eeff3fd6a362bcdf2c7bb400e577c8b73a Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sat, 23 Aug 2025 09:54:22 -0500 Subject: [PATCH] Very minimal MVP --- CLAUDE.md | 9 +- .../vehicles/api/vehicles.controller.ts | 71 ++++++++ .../features/vehicles/api/vehicles.routes.ts | 11 +- .../vehicles/domain/vehicles.service.ts | 80 +++++++++ .../vehicles/domain/vehicles.types.ts | 24 +++ .../vehicles/external/vpic/vpic.client.ts | 102 ++++++++++- .../vehicles/external/vpic/vpic.types.ts | 28 +++ .../002_add_vehicle_dropdown_fields.sql | 39 ++++ docs/security.md | 77 ++++++++ .../src/features/vehicles/api/vehicles.api.ts | 39 +++- .../vehicles/components/VehicleForm.tsx | 170 +++++++++++++++++- .../features/vehicles/types/vehicles.types.ts | 24 +++ 12 files changed, 661 insertions(+), 13 deletions(-) create mode 100644 backend/src/features/vehicles/migrations/002_add_vehicle_dropdown_fields.sql create mode 100644 docs/security.md diff --git a/CLAUDE.md b/CLAUDE.md index 523db4a..dc343af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,8 +3,8 @@ Load .ai/context.json to understand the project's danger zones and loading strat Load AI_README.md and @PROJECT_MAP.md to gain context on the application. CRITICAL: All development practices and choices should be made taking into account the most context effecient interation with another AI. Any AI should be able to understand this applicaiton with minimal prompting. -CRITICAL: All development/testing happens in Docker containers -no local package installations: + +CRITICAL: All development/testing happens in Docker containers, no local package installations: - Development: Dockerfile.dev with npm install during container build - Testing: make test runs tests in container - Rebuilding: make rebuild for code changes @@ -23,11 +23,10 @@ File: frontend/package.json # After each change: make rebuild # Rebuilds containers with new dependencies -make logs-frontend # Monitor for build/runtime errors +make logs # Monitor for build/runtime errors 3. Docker-Tested Component Development - All testing in containers: make shell-frontend for debugging -- File watching works: Vite dev server with --host 0.0.0.0 in -container +- File watching works: Vite dev server with --host 0.0.0.0 in container - Hot reload preserved: Volume mounts sync code changes \ No newline at end of file diff --git a/backend/src/features/vehicles/api/vehicles.controller.ts b/backend/src/features/vehicles/api/vehicles.controller.ts index 80509f3..f6043a0 100644 --- a/backend/src/features/vehicles/api/vehicles.controller.ts +++ b/backend/src/features/vehicles/api/vehicles.controller.ts @@ -161,4 +161,75 @@ export class VehiclesController { next(error); } }; + + getDropdownMakes = async (_req: Request, res: Response, next: NextFunction): Promise => { + try { + const makes = await this.service.getDropdownMakes(); + res.json(makes); + } catch (error: any) { + if (error.message === 'Failed to load makes') { + res.status(503).json({ error: 'Unable to load makes data' }); + return; + } + next(error); + } + }; + + getDropdownModels = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { make } = req.params; + if (!make) { + res.status(400).json({ error: 'Make parameter is required' }); + return; + } + + const models = await this.service.getDropdownModels(make); + res.json(models); + } catch (error: any) { + if (error.message === 'Failed to load models') { + res.status(503).json({ error: 'Unable to load models data' }); + return; + } + next(error); + } + }; + + getDropdownTransmissions = async (_req: Request, res: Response, next: NextFunction): Promise => { + try { + const transmissions = await this.service.getDropdownTransmissions(); + res.json(transmissions); + } catch (error: any) { + if (error.message === 'Failed to load transmissions') { + res.status(503).json({ error: 'Unable to load transmissions data' }); + return; + } + next(error); + } + }; + + getDropdownEngines = async (_req: Request, res: Response, next: NextFunction): Promise => { + try { + const engines = await this.service.getDropdownEngines(); + res.json(engines); + } catch (error: any) { + if (error.message === 'Failed to load engines') { + res.status(503).json({ error: 'Unable to load engines data' }); + return; + } + next(error); + } + }; + + getDropdownTrims = async (_req: Request, res: Response, next: NextFunction): Promise => { + try { + const trims = await this.service.getDropdownTrims(); + res.json(trims); + } catch (error: any) { + if (error.message === 'Failed to load trims') { + res.status(503).json({ error: 'Unable to load trims data' }); + return; + } + next(error); + } + }; } \ No newline at end of file diff --git a/backend/src/features/vehicles/api/vehicles.routes.ts b/backend/src/features/vehicles/api/vehicles.routes.ts index 844ae3b..7b1c966 100644 --- a/backend/src/features/vehicles/api/vehicles.routes.ts +++ b/backend/src/features/vehicles/api/vehicles.routes.ts @@ -11,10 +11,17 @@ export function registerVehiclesRoutes(): Router { const router = Router(); const controller = new VehiclesController(); - // All vehicle routes require authentication + // Dropdown Data Routes (no auth required for form population) + router.get('/api/vehicles/dropdown/makes', controller.getDropdownMakes); + router.get('/api/vehicles/dropdown/models/:make', controller.getDropdownModels); + router.get('/api/vehicles/dropdown/transmissions', controller.getDropdownTransmissions); + router.get('/api/vehicles/dropdown/engines', controller.getDropdownEngines); + router.get('/api/vehicles/dropdown/trims', controller.getDropdownTrims); + + // All other vehicle routes require authentication router.use(authMiddleware); - // Routes + // CRUD Routes router.post('/api/vehicles', controller.createVehicle); router.get('/api/vehicles', controller.getUserVehicles); router.get('/api/vehicles/:id', controller.getVehicle); diff --git a/backend/src/features/vehicles/domain/vehicles.service.ts b/backend/src/features/vehicles/domain/vehicles.service.ts index 576c985..05e5529 100644 --- a/backend/src/features/vehicles/domain/vehicles.service.ts +++ b/backend/src/features/vehicles/domain/vehicles.service.ts @@ -140,6 +140,81 @@ export class VehiclesService { await cacheService.del(cacheKey); } + async getDropdownMakes(): Promise<{ id: number; name: string }[]> { + try { + logger.info('Getting dropdown makes'); + const makes = await vpicClient.getAllMakes(); + + return makes.map(make => ({ + id: make.Make_ID, + name: make.Make_Name + })); + } catch (error) { + logger.error('Failed to get dropdown makes', { error }); + throw new Error('Failed to load makes'); + } + } + + async getDropdownModels(make: string): Promise<{ id: number; name: string }[]> { + try { + logger.info('Getting dropdown models', { make }); + const models = await vpicClient.getModelsForMake(make); + + return models.map(model => ({ + id: model.Model_ID, + name: model.Model_Name + })); + } catch (error) { + logger.error('Failed to get dropdown models', { make, error }); + throw new Error('Failed to load models'); + } + } + + async getDropdownTransmissions(): Promise<{ id: number; name: string }[]> { + try { + logger.info('Getting dropdown transmissions'); + const transmissions = await vpicClient.getTransmissionTypes(); + + return transmissions.map(transmission => ({ + id: transmission.Id, + name: transmission.Name + })); + } catch (error) { + logger.error('Failed to get dropdown transmissions', { error }); + throw new Error('Failed to load transmissions'); + } + } + + async getDropdownEngines(): Promise<{ id: number; name: string }[]> { + try { + logger.info('Getting dropdown engines'); + const engines = await vpicClient.getEngineConfigurations(); + + return engines.map(engine => ({ + id: engine.Id, + name: engine.Name + })); + } catch (error) { + logger.error('Failed to get dropdown engines', { error }); + throw new Error('Failed to load engines'); + } + } + + async getDropdownTrims(): Promise<{ id: number; name: string }[]> { + try { + logger.info('Getting dropdown trims'); + const trims = await vpicClient.getTrimLevels(); + + return trims.map(trim => ({ + id: trim.Id, + name: trim.Name + })); + } catch (error) { + logger.error('Failed to get dropdown trims', { error }); + throw new Error('Failed to load trims'); + } + } + private toResponse(vehicle: Vehicle): VehicleResponse { return { id: vehicle.id, @@ -148,6 +223,11 @@ export class VehiclesService { make: vehicle.make, model: vehicle.model, year: vehicle.year, + engine: vehicle.engine, + transmission: vehicle.transmission, + trimLevel: vehicle.trimLevel, + driveType: vehicle.driveType, + fuelType: vehicle.fuelType, nickname: vehicle.nickname, color: vehicle.color, licensePlate: vehicle.licensePlate, diff --git a/backend/src/features/vehicles/domain/vehicles.types.ts b/backend/src/features/vehicles/domain/vehicles.types.ts index 3d483f3..fae95b8 100644 --- a/backend/src/features/vehicles/domain/vehicles.types.ts +++ b/backend/src/features/vehicles/domain/vehicles.types.ts @@ -10,6 +10,11 @@ export interface Vehicle { make?: string; model?: string; year?: number; + engine?: string; + transmission?: string; + trimLevel?: string; + driveType?: string; + fuelType?: string; nickname?: string; color?: string; licensePlate?: string; @@ -22,6 +27,13 @@ export interface Vehicle { export interface CreateVehicleRequest { vin: string; + make?: string; + model?: string; + engine?: string; + transmission?: string; + trimLevel?: string; + driveType?: string; + fuelType?: string; nickname?: string; color?: string; licensePlate?: string; @@ -29,6 +41,13 @@ export interface CreateVehicleRequest { } export interface UpdateVehicleRequest { + make?: string; + model?: string; + engine?: string; + transmission?: string; + trimLevel?: string; + driveType?: string; + fuelType?: string; nickname?: string; color?: string; licensePlate?: string; @@ -42,6 +61,11 @@ export interface VehicleResponse { make?: string; model?: string; year?: number; + engine?: string; + transmission?: string; + trimLevel?: string; + driveType?: string; + fuelType?: string; nickname?: string; color?: string; licensePlate?: string; diff --git a/backend/src/features/vehicles/external/vpic/vpic.client.ts b/backend/src/features/vehicles/external/vpic/vpic.client.ts index 5bd0b80..68d4a5f 100644 --- a/backend/src/features/vehicles/external/vpic/vpic.client.ts +++ b/backend/src/features/vehicles/external/vpic/vpic.client.ts @@ -7,11 +7,21 @@ import axios from 'axios'; import { env } from '../../../../core/config/environment'; import { logger } from '../../../../core/logging/logger'; import { cacheService } from '../../../../core/config/redis'; -import { VPICResponse, VPICDecodeResult } from './vpic.types'; +import { + VPICResponse, + VPICDecodeResult, + VPICMake, + VPICModel, + VPICTransmission, + VPICEngine, + VPICTrim, + DropdownDataResponse +} from './vpic.types'; export class VPICClient { private readonly baseURL = env.VPIC_API_URL; private readonly cacheTTL = 30 * 24 * 60 * 60; // 30 days in seconds + private readonly dropdownCacheTTL = 7 * 24 * 60 * 60; // 7 days for dropdown data async decodeVIN(vin: string): Promise { const cacheKey = `vpic:vin:${vin}`; @@ -73,6 +83,96 @@ export class VPICClient { rawData: response.Results, }; } + + async getAllMakes(): Promise { + const cacheKey = 'vpic:makes'; + + try { + const cached = await cacheService.get(cacheKey); + if (cached) { + logger.debug('Makes cache hit'); + return cached; + } + + logger.info('Calling vPIC API for makes'); + const response = await axios.get<{ Count: number; Message: string; Results: VPICMake[] }>( + `${this.baseURL}/GetAllMakes?format=json` + ); + + const makes = response.data.Results || []; + await cacheService.set(cacheKey, makes, this.dropdownCacheTTL); + + return makes; + } catch (error) { + logger.error('Get makes failed', { error }); + return []; + } + } + + async getModelsForMake(make: string): Promise { + const cacheKey = `vpic:models:${make}`; + + try { + const cached = await cacheService.get(cacheKey); + if (cached) { + logger.debug('Models cache hit', { make }); + return cached; + } + + logger.info('Calling vPIC API for models', { make }); + const response = await axios.get<{ Count: number; Message: string; Results: VPICModel[] }>( + `${this.baseURL}/GetModelsForMake/${encodeURIComponent(make)}?format=json` + ); + + const models = response.data.Results || []; + await cacheService.set(cacheKey, models, this.dropdownCacheTTL); + + return models; + } catch (error) { + logger.error('Get models failed', { make, error }); + return []; + } + } + + async getTransmissionTypes(): Promise { + return this.getVariableValues('Transmission Style', 'transmissions'); + } + + async getEngineConfigurations(): Promise { + return this.getVariableValues('Engine Configuration', 'engines'); + } + + async getTrimLevels(): Promise { + return this.getVariableValues('Trim', 'trims'); + } + + private async getVariableValues( + variable: string, + cachePrefix: string + ): Promise { + const cacheKey = `vpic:${cachePrefix}`; + + try { + const cached = await cacheService.get(cacheKey); + if (cached) { + logger.debug('Variable values cache hit', { variable }); + return cached; + } + + logger.info('Calling vPIC API for variable values', { variable }); + const response = await axios.get( + `${this.baseURL}/GetVehicleVariableValuesList/${encodeURIComponent(variable)}?format=json` + ); + + const values = response.data.Results || []; + await cacheService.set(cacheKey, values, this.dropdownCacheTTL); + + return values; + } catch (error) { + logger.error('Get variable values failed', { variable, error }); + return []; + } + } } export const vpicClient = new VPICClient(); \ No newline at end of file diff --git a/backend/src/features/vehicles/external/vpic/vpic.types.ts b/backend/src/features/vehicles/external/vpic/vpic.types.ts index 5222e0f..a5c8ab0 100644 --- a/backend/src/features/vehicles/external/vpic/vpic.types.ts +++ b/backend/src/features/vehicles/external/vpic/vpic.types.ts @@ -23,4 +23,32 @@ export interface VPICDecodeResult { engineType?: string; bodyType?: string; rawData: VPICResult[]; +} + +export interface VPICMake { + Make_ID: number; + Make_Name: string; +} + +export interface VPICModel { + Make_ID: number; + Make_Name: string; + Model_ID: number; + Model_Name: string; +} + +export interface VPICDropdownItem { + Id: number; + Name: string; +} + +export interface VPICTransmission extends VPICDropdownItem {} +export interface VPICEngine extends VPICDropdownItem {} +export interface VPICTrim extends VPICDropdownItem {} + +export interface DropdownDataResponse { + Count: number; + Message: string; + SearchCriteria: string; + Results: VPICDropdownItem[]; } \ No newline at end of file diff --git a/backend/src/features/vehicles/migrations/002_add_vehicle_dropdown_fields.sql b/backend/src/features/vehicles/migrations/002_add_vehicle_dropdown_fields.sql new file mode 100644 index 0000000..7a3deeb --- /dev/null +++ b/backend/src/features/vehicles/migrations/002_add_vehicle_dropdown_fields.sql @@ -0,0 +1,39 @@ +-- Add new dropdown fields to vehicles table +ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS engine VARCHAR(100); +ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS transmission VARCHAR(100); +ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS trim_level VARCHAR(100); +ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS drive_type VARCHAR(50); +ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS fuel_type VARCHAR(50); + +-- Create indexes for new fields for potential future searches +CREATE INDEX IF NOT EXISTS idx_vehicles_make ON vehicles(make); +CREATE INDEX IF NOT EXISTS idx_vehicles_model ON vehicles(model); +CREATE INDEX IF NOT EXISTS idx_vehicles_engine ON vehicles(engine); +CREATE INDEX IF NOT EXISTS idx_vehicles_transmission ON vehicles(transmission); +CREATE INDEX IF NOT EXISTS idx_vehicles_trim_level ON vehicles(trim_level); + +-- Create dropdown cache table for performance optimization +CREATE TABLE IF NOT EXISTS vehicle_dropdown_cache ( + cache_key VARCHAR(255) PRIMARY KEY, + data JSONB NOT NULL, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Create index on expiry for cleanup tasks +CREATE INDEX IF NOT EXISTS idx_dropdown_cache_expires_at ON vehicle_dropdown_cache(expires_at); + +-- Create trigger for updating updated_at on dropdown cache +CREATE TRIGGER IF NOT EXISTS update_dropdown_cache_updated_at + BEFORE UPDATE ON vehicle_dropdown_cache + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Add comments for documentation +COMMENT ON COLUMN vehicles.engine IS 'Engine configuration from NHTSA vPIC API'; +COMMENT ON COLUMN vehicles.transmission IS 'Transmission style from NHTSA vPIC API'; +COMMENT ON COLUMN vehicles.trim_level IS 'Trim level from NHTSA vPIC API'; +COMMENT ON COLUMN vehicles.drive_type IS 'Drive type (FWD, RWD, AWD, 4WD)'; +COMMENT ON COLUMN vehicles.fuel_type IS 'Primary fuel type'; +COMMENT ON TABLE vehicle_dropdown_cache IS 'Cache for dropdown data from NHTSA vPIC API'; \ No newline at end of file diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..bc8debf --- /dev/null +++ b/docs/security.md @@ -0,0 +1,77 @@ +# Security Architecture + +## Authentication & Authorization + +### Protected Endpoints +All vehicle CRUD operations require JWT authentication via Auth0: +- `POST /api/vehicles` - Create vehicle +- `GET /api/vehicles` - Get user vehicles +- `GET /api/vehicles/:id` - Get specific vehicle +- `PUT /api/vehicles/:id` - Update vehicle +- `DELETE /api/vehicles/:id` - Delete vehicle + +### Unauthenticated Endpoints + +#### Vehicle Dropdown Data API +The following endpoints are intentionally unauthenticated to support form population before user login: + +``` +GET /api/vehicles/dropdown/makes +GET /api/vehicles/dropdown/models/:make +GET /api/vehicles/dropdown/transmissions +GET /api/vehicles/dropdown/engines +GET /api/vehicles/dropdown/trims +``` + +**Security Considerations:** +- **Data Exposure**: Only exposes public NHTSA vPIC vehicle specification data +- **No User Data**: Contains no sensitive user information or business logic +- **Read-Only**: All endpoints are GET requests with no mutations +- **Caching**: 7-day Redis caching reduces external API abuse +- **Error Handling**: Generic error responses prevent system information disclosure + +**Known Risks:** +1. **API Abuse**: No rate limiting allows unlimited calls +2. **Resource Consumption**: Could exhaust NHTSA API rate limits +3. **Cache Poisoning**: Limited input validation on make parameter +4. **Information Disclosure**: Exposes system capabilities to unauthenticated users + +**Recommended Mitigations for Production:** +1. **Rate Limiting**: Implement express-rate-limit (e.g., 100 requests/hour per IP) +2. **Input Validation**: Sanitize make parameter in controller +3. **CORS Restrictions**: Limit to application domain +4. **Monitoring**: Add abuse detection logging +5. **API Gateway**: Consider moving to API gateway with built-in rate limiting + +**Risk Assessment**: ACCEPTABLE for MVP +- Low risk due to public data exposure only +- UX benefits outweigh security concerns +- Mitigations can be added incrementally + +## Data Security + +### VIN Handling +- VIN validation using industry-standard check digit algorithm +- VIN decoding via NHTSA vPIC API +- Cached VIN decode results (30-day TTL) +- No VIN storage in logs (masked in logging middleware) + +### Database Security +- User data isolation via userId foreign keys +- Soft deletes for audit trail +- No cascading deletes to prevent data loss +- Encrypted connections to PostgreSQL + +## Infrastructure Security + +### Docker Security +- Development containers run as non-root users +- Network isolation between services +- Environment variable injection for secrets +- No hardcoded credentials in images + +### API Client Security +- Separate authenticated/unauthenticated HTTP clients +- Request/response interceptors for error handling +- Timeout configurations to prevent hanging requests +- Auth token handling via Auth0 wrapper \ No newline at end of file diff --git a/frontend/src/features/vehicles/api/vehicles.api.ts b/frontend/src/features/vehicles/api/vehicles.api.ts index 2c8d172..8ed2750 100644 --- a/frontend/src/features/vehicles/api/vehicles.api.ts +++ b/frontend/src/features/vehicles/api/vehicles.api.ts @@ -3,7 +3,18 @@ */ import { apiClient } from '../../../core/api/client'; -import { Vehicle, CreateVehicleRequest, UpdateVehicleRequest } from '../types/vehicles.types'; +import axios from 'axios'; +import { Vehicle, CreateVehicleRequest, UpdateVehicleRequest, DropdownOption } from '../types/vehicles.types'; + +// Unauthenticated client for dropdown data +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'; +const dropdownClient = axios.create({ + baseURL: API_BASE_URL, + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, +}); export const vehiclesApi = { getAll: async (): Promise => { @@ -29,4 +40,30 @@ export const vehiclesApi = { delete: async (id: string): Promise => { await apiClient.delete(`/vehicles/${id}`); }, + + // Dropdown API methods (unauthenticated) + getMakes: async (): Promise => { + const response = await dropdownClient.get('/vehicles/dropdown/makes'); + return response.data; + }, + + getModels: async (make: string): Promise => { + const response = await dropdownClient.get(`/vehicles/dropdown/models/${encodeURIComponent(make)}`); + return response.data; + }, + + getTransmissions: async (): Promise => { + const response = await dropdownClient.get('/vehicles/dropdown/transmissions'); + return response.data; + }, + + getEngines: async (): Promise => { + const response = await dropdownClient.get('/vehicles/dropdown/engines'); + return response.data; + }, + + getTrims: async (): Promise => { + const response = await dropdownClient.get('/vehicles/dropdown/trims'); + return response.data; + }, }; \ No newline at end of file diff --git a/frontend/src/features/vehicles/components/VehicleForm.tsx b/frontend/src/features/vehicles/components/VehicleForm.tsx index aab881d..36981a0 100644 --- a/frontend/src/features/vehicles/components/VehicleForm.tsx +++ b/frontend/src/features/vehicles/components/VehicleForm.tsx @@ -1,16 +1,24 @@ /** - * @ai-summary Vehicle form component for create/edit + * @ai-summary Vehicle form component for create/edit with dropdown cascades */ -import React from 'react'; +import React, { useState, useEffect } from 'react'; 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 } from '../types/vehicles.types'; +import { CreateVehicleRequest, DropdownOption } from '../types/vehicles.types'; +import { vehiclesApi } from '../api/vehicles.api'; const vehicleSchema = z.object({ vin: z.string().length(17, 'VIN must be exactly 17 characters'), + make: z.string().optional(), + model: z.string().optional(), + engine: z.string().optional(), + transmission: z.string().optional(), + trimLevel: z.string().optional(), + driveType: z.string().optional(), + fuelType: z.string().optional(), nickname: z.string().optional(), color: z.string().optional(), licensePlate: z.string().optional(), @@ -30,15 +38,74 @@ export const VehicleForm: React.FC = ({ initialData, loading, }) => { + const [makes, setMakes] = useState([]); + const [models, setModels] = useState([]); + const [transmissions, setTransmissions] = useState([]); + const [engines, setEngines] = useState([]); + const [trims, setTrims] = useState([]); + const [selectedMake, setSelectedMake] = useState(''); + const [loadingDropdowns, setLoadingDropdowns] = useState(false); + const { register, handleSubmit, formState: { errors }, + watch, + setValue, } = useForm({ resolver: zodResolver(vehicleSchema), defaultValues: initialData, }); + const watchedMake = watch('make'); + + // Load dropdown data on component mount + useEffect(() => { + const loadInitialData = async () => { + setLoadingDropdowns(true); + try { + const [makesData, transmissionsData, enginesData, trimsData] = await Promise.all([ + vehiclesApi.getMakes(), + vehiclesApi.getTransmissions(), + vehiclesApi.getEngines(), + vehiclesApi.getTrims(), + ]); + + setMakes(makesData); + setTransmissions(transmissionsData); + setEngines(enginesData); + setTrims(trimsData); + } catch (error) { + console.error('Failed to load dropdown data:', error); + } finally { + setLoadingDropdowns(false); + } + }; + + loadInitialData(); + }, []); + + // Load models when make changes + useEffect(() => { + if (watchedMake && watchedMake !== selectedMake) { + const loadModels = async () => { + try { + const modelsData = await vehiclesApi.getModels(watchedMake); + setModels(modelsData); + setSelectedMake(watchedMake); + + // Clear model selection when make changes + setValue('model', ''); + } catch (error) { + console.error('Failed to load models:', error); + setModels([]); + } + }; + + loadModels(); + } + }, [watchedMake, selectedMake, setValue]); + return (
@@ -55,6 +122,101 @@ export const VehicleForm: React.FC = ({ )}
+ {/* Vehicle Specification Dropdowns */} +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
diff --git a/frontend/src/features/vehicles/types/vehicles.types.ts b/frontend/src/features/vehicles/types/vehicles.types.ts index 7e1e37d..109393c 100644 --- a/frontend/src/features/vehicles/types/vehicles.types.ts +++ b/frontend/src/features/vehicles/types/vehicles.types.ts @@ -9,6 +9,11 @@ export interface Vehicle { make?: string; model?: string; year?: number; + engine?: string; + transmission?: string; + trimLevel?: string; + driveType?: string; + fuelType?: string; nickname?: string; color?: string; licensePlate?: string; @@ -20,6 +25,13 @@ export interface Vehicle { export interface CreateVehicleRequest { vin: string; + make?: string; + model?: string; + engine?: string; + transmission?: string; + trimLevel?: string; + driveType?: string; + fuelType?: string; nickname?: string; color?: string; licensePlate?: string; @@ -27,8 +39,20 @@ export interface CreateVehicleRequest { } export interface UpdateVehicleRequest { + make?: string; + model?: string; + engine?: string; + transmission?: string; + trimLevel?: string; + driveType?: string; + fuelType?: string; nickname?: string; color?: string; licensePlate?: string; odometerReading?: number; +} + +export interface DropdownOption { + id: number; + name: string; } \ No newline at end of file