639 lines
21 KiB
TypeScript
639 lines
21 KiB
TypeScript
/**
|
|
* @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 } from '../types/vehicles.types';
|
|
import { vehiclesApi } from '../api/vehicles.api';
|
|
|
|
const vehicleSchema = z
|
|
.object({
|
|
vin: z.string().optional(),
|
|
year: z.number().min(1980).max(new Date().getFullYear() + 1).optional(),
|
|
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(),
|
|
odometerReading: z.number().min(0).optional(),
|
|
})
|
|
.refine(
|
|
(data) => {
|
|
const vin = (data.vin || '').trim();
|
|
const plate = (data.licensePlate || '').trim();
|
|
// 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) => {
|
|
const vin = (data.vin || '').trim();
|
|
const plate = (data.licensePlate || '').trim();
|
|
// If VIN provided but not 17 and no plate, fail; if plate exists, allow any VIN (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<CreateVehicleRequest>;
|
|
loading?: boolean;
|
|
}
|
|
|
|
export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|
onSubmit,
|
|
onCancel,
|
|
initialData,
|
|
loading,
|
|
}) => {
|
|
const [years, setYears] = useState<number[]>([]);
|
|
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<string>('');
|
|
const [selectedModel, setSelectedModel] = useState<string>('');
|
|
const [selectedTrim, setSelectedTrim] = useState<string>('');
|
|
const [loadingDropdowns, setLoadingDropdowns] = useState(false);
|
|
const [decodingVIN, setDecodingVIN] = useState(false);
|
|
const [decodeSuccess, setDecodeSuccess] = useState(false);
|
|
const hasInitialized = useRef(false);
|
|
const isInitializing = useRef(false);
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
formState: { errors },
|
|
watch,
|
|
setValue,
|
|
reset,
|
|
} = useForm<CreateVehicleRequest>({
|
|
resolver: zodResolver(vehicleSchema),
|
|
defaultValues: initialData,
|
|
});
|
|
|
|
const watchedYear = watch('year');
|
|
const watchedMake = watch('make');
|
|
const watchedModel = watch('model');
|
|
const watchedVIN = watch('vin');
|
|
|
|
// VIN decode handler
|
|
const handleDecodeVIN = async () => {
|
|
const vin = watchedVIN;
|
|
if (!vin || vin.length !== 17) {
|
|
return;
|
|
}
|
|
|
|
setDecodingVIN(true);
|
|
setDecodeSuccess(false);
|
|
|
|
try {
|
|
const result = await vehiclesApi.decodeVIN(vin);
|
|
if (result.success) {
|
|
// Auto-populate fields with decoded values
|
|
if (result.year) setValue('year', result.year);
|
|
if (result.make) setValue('make', result.make);
|
|
if (result.model) setValue('model', result.model);
|
|
if (result.transmission) setValue('transmission', result.transmission);
|
|
if (result.engine) setValue('engine', result.engine);
|
|
if (result.trimLevel) setValue('trimLevel', result.trimLevel);
|
|
|
|
setDecodeSuccess(true);
|
|
setTimeout(() => setDecodeSuccess(false), 3000); // Hide success after 3 seconds
|
|
}
|
|
} catch (error) {
|
|
console.error('VIN decode failed:', error);
|
|
} finally {
|
|
setDecodingVIN(false);
|
|
}
|
|
};
|
|
|
|
// 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
|
|
setSelectedYear(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
|
|
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);
|
|
}
|
|
};
|
|
|
|
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 !== selectedYear) {
|
|
const loadMakes = async () => {
|
|
setLoadingDropdowns(true);
|
|
try {
|
|
const makesData = await vehiclesApi.getMakes(watchedYear);
|
|
setMakes(makesData);
|
|
setSelectedYear(watchedYear);
|
|
|
|
// Clear dependent selections
|
|
setSelectedMake('');
|
|
setSelectedModel('');
|
|
setSelectedTrim('');
|
|
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, selectedYear, setValue]);
|
|
|
|
// Load models when make changes
|
|
useEffect(() => {
|
|
// Skip during initialization
|
|
if (isInitializing.current) return;
|
|
|
|
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
|
|
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();
|
|
}
|
|
}, [watchedMake, watchedYear, selectedMake, setValue]);
|
|
|
|
// Load trims and transmissions when model changes
|
|
useEffect(() => {
|
|
// Skip during initialization
|
|
if (isInitializing.current) return;
|
|
|
|
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);
|
|
|
|
// 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, setValue]);
|
|
|
|
// Load engines when trim changes
|
|
useEffect(() => {
|
|
// Skip during initialization
|
|
if (isInitializing.current) return;
|
|
|
|
const trimName = watch('trimLevel');
|
|
if (trimName && watchedYear && selectedMake && selectedModel) {
|
|
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();
|
|
}
|
|
}, [watchedYear, selectedMake, selectedModel, watch('trimLevel')]);
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
VIN or License Plate <span className="text-red-500">*</span>
|
|
</label>
|
|
<p className="text-xs text-gray-600 mb-2">
|
|
Enter VIN to auto-fill vehicle details OR manually select from dropdowns below
|
|
</p>
|
|
<div className="flex flex-col sm:flex-row gap-2">
|
|
<input
|
|
{...register('vin')}
|
|
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 text-base"
|
|
placeholder="Enter 17-character VIN (optional if License Plate provided)"
|
|
style={{ fontSize: '16px' }}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
onClick={handleDecodeVIN}
|
|
loading={decodingVIN}
|
|
disabled={!watchedVIN || watchedVIN.length !== 17}
|
|
variant="secondary"
|
|
className="w-full sm:w-auto min-h-[44px]"
|
|
>
|
|
Decode VIN
|
|
</Button>
|
|
</div>
|
|
{decodeSuccess && (
|
|
<p className="mt-1 text-sm text-green-600">VIN decoded successfully! Fields populated.</p>
|
|
)}
|
|
{errors.vin && (
|
|
<p className="mt-1 text-sm text-red-600">{errors.vin.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Vehicle Specification Dropdowns */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Year
|
|
</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' }}
|
|
>
|
|
<option value="">Select Year</option>
|
|
{years.map((year) => (
|
|
<option key={year} value={year}>
|
|
{year}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Make
|
|
</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 || makes.length === 0}
|
|
style={{ fontSize: '16px' }}
|
|
>
|
|
<option value="">
|
|
{loadingDropdowns
|
|
? 'Loading...'
|
|
: !watchedYear
|
|
? 'Select year first'
|
|
: makes.length === 0
|
|
? 'No makes available'
|
|
: 'Select Make'}
|
|
</option>
|
|
{makes.map((make) => (
|
|
<option key={make} value={make}>
|
|
{make}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Model
|
|
</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="">
|
|
{loadingDropdowns
|
|
? 'Loading...'
|
|
: !watchedMake
|
|
? 'Select make first'
|
|
: models.length === 0
|
|
? 'No models available'
|
|
: 'Select Model'}
|
|
</option>
|
|
{models.map((model) => (
|
|
<option key={model} value={model}>
|
|
{model}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
|
{/* Trim (left) */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Trim
|
|
</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="">
|
|
{loadingDropdowns
|
|
? 'Loading...'
|
|
: !watchedModel
|
|
? 'Select model first'
|
|
: trims.length === 0
|
|
? 'No trims available'
|
|
: 'Select Trim'}
|
|
</option>
|
|
{trims.map((trim) => (
|
|
<option key={trim} value={trim}>
|
|
{trim}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Engine (middle) */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Engine
|
|
</label>
|
|
<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 || !selectedTrim || engines.length === 0}
|
|
style={{ fontSize: '16px' }}
|
|
>
|
|
<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>
|
|
</div>
|
|
|
|
{/* Transmission (right) */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Transmission
|
|
</label>
|
|
<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 || !watchedModel || transmissions.length === 0}
|
|
style={{ fontSize: '16px' }}
|
|
>
|
|
<option value="">
|
|
{loadingDropdowns
|
|
? 'Loading...'
|
|
: !watchedModel
|
|
? 'Select model first'
|
|
: transmissions.length === 0
|
|
? 'No transmissions available'
|
|
: 'Select Transmission'}
|
|
</option>
|
|
{transmissions.map((transmission) => (
|
|
<option key={transmission} value={transmission}>
|
|
{transmission}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Nickname
|
|
</label>
|
|
<input
|
|
{...register('nickname')}
|
|
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]"
|
|
placeholder="e.g., Family Car"
|
|
style={{ fontSize: '16px' }}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Color
|
|
</label>
|
|
<input
|
|
{...register('color')}
|
|
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]"
|
|
placeholder="e.g., Blue"
|
|
style={{ fontSize: '16px' }}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
License Plate
|
|
</label>
|
|
<input
|
|
{...register('licensePlate')}
|
|
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]"
|
|
placeholder="e.g., ABC-123 (required if VIN omitted)"
|
|
style={{ fontSize: '16px' }}
|
|
/>
|
|
{errors.licensePlate && (
|
|
<p className="mt-1 text-sm text-red-600">{errors.licensePlate.message}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Current Odometer Reading
|
|
</label>
|
|
<input
|
|
{...register('odometerReading', { valueAsNumber: true })}
|
|
type="number"
|
|
inputMode="numeric"
|
|
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]"
|
|
placeholder="e.g., 50000"
|
|
style={{ fontSize: '16px' }}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-3 justify-end pt-4">
|
|
<Button variant="secondary" onClick={onCancel} type="button">
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" loading={loading || loadingDropdowns}>
|
|
{initialData ? 'Update Vehicle' : 'Add Vehicle'}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
};
|