/** * @ai-summary Vehicle form component for create/edit with dropdown cascades */ import React, { useState, useEffect, useRef } 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, Vehicle } from '../types/vehicles.types'; import { vehiclesApi } from '../api/vehicles.api'; import { VehicleImageUpload } from './VehicleImageUpload'; const vehicleSchema = z .object({ vin: z.string().max(17).nullable().optional().transform(val => val ?? undefined), year: z.number().min(1950).max(new Date().getFullYear() + 1).nullable().optional(), make: z.string().nullable().optional(), model: z.string().nullable().optional(), engine: z.string().nullable().optional(), transmission: z.string().nullable().optional(), trimLevel: z.string().nullable().optional(), driveType: z.string().nullable().optional(), fuelType: z.string().nullable().optional(), nickname: z.string().nullable().optional(), color: z.string().nullable().optional(), licensePlate: z.string().nullable().optional(), odometerReading: z.number().min(0).nullable().optional(), }) .refine( (data) => { // Pre-1981 vehicles have no VIN/plate requirement if (data.year && data.year < 1981) { return true; } const vin = (data.vin || '').trim(); const plate = (data.licensePlate || '').trim(); // 1981+: Must have either a valid 17-char VIN or a non-empty license plate if (vin.length === 17) return true; if (plate.length > 0) return true; return false; }, { message: 'Either a valid 17-character VIN or a license plate is required', path: ['vin'], } ) .refine( (data) => { // Pre-1981 vehicles accept any length VIN (1-17 chars) if (data.year && data.year < 1981) { const vin = (data.vin || '').trim(); // Empty is fine, or any length up to 17 return vin.length === 0 || (vin.length >= 1 && vin.length <= 17); } const vin = (data.vin || '').trim(); const plate = (data.licensePlate || '').trim(); // 1981+: If plate exists, allow any VIN (or empty); otherwise VIN must be exactly 17 or empty if (plate.length > 0) return true; return vin.length === 17 || vin.length === 0; }, { message: 'VIN must be exactly 17 characters when license plate is not provided', path: ['vin'], } ); interface VehicleFormProps { onSubmit: (data: CreateVehicleRequest) => void; onCancel: () => void; initialData?: Partial & { id?: string; imageUrl?: string }; loading?: boolean; onImageUpdate?: (vehicle: Vehicle) => void; onStagedImage?: (file: File | null) => void; } export const VehicleForm: React.FC = ({ onSubmit, onCancel, initialData, loading, onImageUpdate, onStagedImage, }) => { const [years, setYears] = useState([]); const [makes, setMakes] = useState([]); const [models, setModels] = useState([]); const [engines, setEngines] = useState([]); const [trims, setTrims] = useState([]); const [transmissions, setTransmissions] = useState([]); const [loadingDropdowns, setLoadingDropdowns] = useState(false); const hasInitialized = useRef(false); const isInitializing = useRef(false); // Track previous values for cascade change detection (replaces useState) const prevYear = useRef(undefined); const prevMake = useRef(''); const prevModel = useRef(''); const prevTrim = useRef(''); const [currentImageUrl, setCurrentImageUrl] = useState(initialData?.imageUrl); const [previewUrl, setPreviewUrl] = useState(null); const isEditMode = !!initialData?.id; const vehicleId = initialData?.id; const { register, handleSubmit, formState: { errors }, watch, setValue, reset, } = useForm({ resolver: zodResolver(vehicleSchema), defaultValues: initialData, }); const watchedYear = watch('year'); const watchedMake = watch('make'); const watchedModel = watch('model'); const watchedTrim = watch('trimLevel'); const watchedEngine = watch('engine'); const watchedTransmission = watch('transmission'); // Load years on component mount useEffect(() => { const loadYears = async () => { try { const yearsData = await vehiclesApi.getYears(); setYears(yearsData); } catch (error) { console.error('Failed to load years:', error); } }; loadYears(); }, []); // Initialize dropdowns when editing existing vehicle useEffect(() => { const initializeEditMode = async () => { // Only run once and only if we have initialData if (hasInitialized.current || !initialData || !initialData.year) return; hasInitialized.current = true; isInitializing.current = true; try { setLoadingDropdowns(true); // Step 1: Set year and load makes prevYear.current = initialData.year; const makesData = await vehiclesApi.getMakes(initialData.year); setMakes(makesData); if (!initialData.make) { isInitializing.current = false; return; } // Step 2: Set make and load models prevMake.current = 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 loaded after trim selected) prevModel.current = initialData.model; const trimsData = await vehiclesApi.getTrims(initialData.year, initialData.make, initialData.model); setTrims(trimsData); if (initialData.trimLevel) { // Step 4: Set trim and load engines + transmissions prevTrim.current = initialData.trimLevel; const [enginesData, transmissionsData] = await Promise.all([ vehiclesApi.getEngines( initialData.year, initialData.make, initialData.model, initialData.trimLevel ), vehiclesApi.getTransmissions( initialData.year, initialData.make, initialData.model, initialData.trimLevel ) ]); setEngines(enginesData); setTransmissions(transmissionsData); } isInitializing.current = false; } catch (error) { console.error('Failed to initialize edit mode:', error); isInitializing.current = false; } finally { setLoadingDropdowns(false); } }; initializeEditMode(); }, [initialData]); // Run when initialData is available // Reset form values after initialization useEffect(() => { if (!isInitializing.current && initialData) { reset(initialData); } }, [initialData, reset]); // Load makes when year changes useEffect(() => { // Skip during initialization if (isInitializing.current) return; if (watchedYear && watchedYear !== prevYear.current) { const loadMakes = async () => { setLoadingDropdowns(true); try { const makesData = await vehiclesApi.getMakes(watchedYear); setMakes(makesData); prevYear.current = watchedYear; // Clear dependent selections prevMake.current = ''; prevModel.current = ''; prevTrim.current = ''; setModels([]); setTrims([]); setEngines([]); setTransmissions([]); setValue('make', ''); setValue('model', ''); setValue('trimLevel', ''); setValue('transmission', ''); setValue('engine', ''); } catch (error) { console.error('Failed to load makes:', error); setMakes([]); } finally { setLoadingDropdowns(false); } }; loadMakes(); } }, [watchedYear, setValue]); // Load models when make changes useEffect(() => { // Skip during initialization if (isInitializing.current) return; if (watchedMake && watchedYear && watchedMake !== prevMake.current) { const loadModels = async () => { setLoadingDropdowns(true); try { const modelsData = await vehiclesApi.getModels(watchedYear, watchedMake); setModels(modelsData); prevMake.current = watchedMake; // Clear dependent selections prevModel.current = ''; prevTrim.current = ''; 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(); } }, [watchedMake, watchedYear, setValue]); // Load trims when model changes useEffect(() => { // Skip during initialization if (isInitializing.current) return; if (watchedModel && watchedYear && watchedMake && watchedModel !== prevModel.current) { const loadTrims = async () => { setLoadingDropdowns(true); try { const trimsData = await vehiclesApi.getTrims(watchedYear, watchedMake, watchedModel); setTrims(trimsData); prevModel.current = watchedModel; // Clear deeper selections (engines, transmissions) prevTrim.current = ''; setTransmissions([]); setEngines([]); setValue('trimLevel', ''); setValue('transmission', ''); setValue('engine', ''); } catch (error) { console.error('Failed to load trims:', error); setTrims([]); } finally { setLoadingDropdowns(false); } }; loadTrims(); } }, [watchedModel, watchedYear, watchedMake, setValue]); // Load engines and transmissions when trim changes useEffect(() => { // Skip during initialization if (isInitializing.current) return; if (watchedTrim && watchedYear && watchedMake && watchedModel && watchedTrim !== prevTrim.current) { const loadEnginesAndTransmissions = async () => { setLoadingDropdowns(true); try { const [enginesData, transmissionsData] = await Promise.all([ vehiclesApi.getEngines( watchedYear, watchedMake, watchedModel, watchedTrim ), vehiclesApi.getTransmissions( watchedYear, watchedMake, watchedModel, watchedTrim ) ]); setEngines(enginesData); setTransmissions(transmissionsData); prevTrim.current = watchedTrim; } catch (error) { console.error('Failed to load engines and transmissions:', error); setEngines([]); setTransmissions([]); } finally { setLoadingDropdowns(false); } }; loadEnginesAndTransmissions(); } }, [watchedYear, watchedMake, watchedModel, watchedTrim, setValue]); const handleImageUpload = async (file: File) => { if (isEditMode && vehicleId) { // Edit mode: upload immediately to server const updated = await vehiclesApi.uploadImage(vehicleId, file); setCurrentImageUrl(updated.imageUrl); onImageUpdate?.(updated); } else { // Create mode: stage file locally for upload after vehicle creation const objectUrl = URL.createObjectURL(file); setPreviewUrl(objectUrl); onStagedImage?.(file); } }; const handleImageRemove = async () => { if (isEditMode && vehicleId) { // Edit mode: delete from server await vehiclesApi.deleteImage(vehicleId); setCurrentImageUrl(undefined); if (initialData) { onImageUpdate?.({ ...initialData, imageUrl: undefined } as Vehicle); } } else { // Create mode: clear staged file if (previewUrl) { URL.revokeObjectURL(previewUrl); } setPreviewUrl(null); onStagedImage?.(null); } }; // Watch current form values for image preview (uses make for logo fallback) const watchedColor = watch('color'); const currentMake = watch('make') || initialData?.make; const vehicleForImage: Vehicle = { id: vehicleId || '', userId: '', vin: initialData?.vin || '', make: currentMake, model: initialData?.model, year: initialData?.year, color: watchedColor || initialData?.color, odometerReading: initialData?.odometerReading || 0, isActive: true, createdAt: '', updatedAt: '', imageUrl: previewUrl || currentImageUrl, }; return (

Enter vehicle VIN (optional)

{errors.vin && (

{errors.vin.message}

)}
{/* Vehicle Specification Dropdowns */}
{/* Trim (left) */}
{/* Engine (middle) */}
{/* Transmission (right) */}
{errors.licensePlate && (

{errors.licensePlate.message}

)}
); };