Initial Commit
This commit is contained in:
@@ -10,20 +10,49 @@ import { Button } from '../../../shared-minimal/components/Button';
|
||||
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(),
|
||||
odometerReading: z.number().min(0).optional(),
|
||||
});
|
||||
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;
|
||||
@@ -38,13 +67,18 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
initialData,
|
||||
loading,
|
||||
}) => {
|
||||
const [years, setYears] = useState<number[]>([]);
|
||||
const [makes, setMakes] = useState<DropdownOption[]>([]);
|
||||
const [models, setModels] = useState<DropdownOption[]>([]);
|
||||
const [transmissions, setTransmissions] = useState<DropdownOption[]>([]);
|
||||
const [engines, setEngines] = useState<DropdownOption[]>([]);
|
||||
const [trims, setTrims] = useState<DropdownOption[]>([]);
|
||||
const [selectedMake, setSelectedMake] = useState<string>('');
|
||||
const [selectedYear, setSelectedYear] = useState<number | undefined>();
|
||||
const [selectedMake, setSelectedMake] = useState<DropdownOption | undefined>();
|
||||
const [selectedModel, setSelectedModel] = useState<DropdownOption | undefined>();
|
||||
const [loadingDropdowns, setLoadingDropdowns] = useState(false);
|
||||
const [selectedTrim, setSelectedTrim] = useState<DropdownOption | undefined>();
|
||||
const [decodingVIN, setDecodingVIN] = useState(false);
|
||||
const [decodeSuccess, setDecodeSuccess] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -57,73 +91,226 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
defaultValues: initialData,
|
||||
});
|
||||
|
||||
const watchedYear = watch('year');
|
||||
const watchedMake = watch('make');
|
||||
const watchedModel = watch('model');
|
||||
const watchedVIN = watch('vin');
|
||||
|
||||
// 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(),
|
||||
]);
|
||||
// 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);
|
||||
|
||||
setMakes(makesData);
|
||||
setTransmissions(transmissionsData);
|
||||
setEngines(enginesData);
|
||||
setTrims(trimsData);
|
||||
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 dropdown data:', error);
|
||||
} finally {
|
||||
setLoadingDropdowns(false);
|
||||
console.error('Failed to load years:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadInitialData();
|
||||
loadYears();
|
||||
}, []);
|
||||
|
||||
// Load models when make changes
|
||||
// Load makes when year changes
|
||||
useEffect(() => {
|
||||
if (watchedMake && watchedMake !== selectedMake) {
|
||||
const loadModels = async () => {
|
||||
if (watchedYear && watchedYear !== selectedYear) {
|
||||
const loadMakes = async () => {
|
||||
setLoadingDropdowns(true);
|
||||
try {
|
||||
const modelsData = await vehiclesApi.getModels(watchedMake);
|
||||
setModels(modelsData);
|
||||
setSelectedMake(watchedMake);
|
||||
const makesData = await vehiclesApi.getMakes(watchedYear);
|
||||
setMakes(makesData);
|
||||
setSelectedYear(watchedYear);
|
||||
|
||||
// Clear model selection when make changes
|
||||
setValue('model', '');
|
||||
} catch (error) {
|
||||
console.error('Failed to load models:', error);
|
||||
// Clear dependent selections
|
||||
setModels([]);
|
||||
setEngines([]);
|
||||
setTrims([]);
|
||||
setSelectedMake(undefined);
|
||||
setSelectedModel(undefined);
|
||||
setValue('make', '');
|
||||
setValue('model', '');
|
||||
setValue('transmission', '');
|
||||
setValue('engine', '');
|
||||
setValue('trimLevel', '');
|
||||
} catch (error) {
|
||||
console.error('Failed to load makes:', error);
|
||||
setMakes([]);
|
||||
} finally {
|
||||
setLoadingDropdowns(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadModels();
|
||||
loadMakes();
|
||||
}
|
||||
}, [watchedMake, selectedMake, setValue]);
|
||||
}, [watchedYear, selectedYear, setValue]);
|
||||
|
||||
// Load models when make changes
|
||||
useEffect(() => {
|
||||
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);
|
||||
|
||||
// Clear dependent selections
|
||||
setEngines([]);
|
||||
setTrims([]);
|
||||
setSelectedModel(undefined);
|
||||
setValue('model', '');
|
||||
setValue('transmission', '');
|
||||
setValue('engine', '');
|
||||
setValue('trimLevel', '');
|
||||
} catch (error) {
|
||||
console.error('Failed to load models:', error);
|
||||
setModels([]);
|
||||
} finally {
|
||||
setLoadingDropdowns(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadModels();
|
||||
}
|
||||
}
|
||||
}, [watchedMake, watchedYear, selectedMake, makes, setValue]);
|
||||
|
||||
// Load trims when model changes
|
||||
useEffect(() => {
|
||||
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 = await vehiclesApi.getTrims(watchedYear, selectedMake.id, modelOption.id);
|
||||
setTrims(trimsData);
|
||||
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([]);
|
||||
} finally {
|
||||
setLoadingDropdowns(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadTrims();
|
||||
}
|
||||
}
|
||||
}, [watchedModel, watchedYear, selectedMake, selectedModel, models, setValue]);
|
||||
|
||||
// Load engines when trim changes
|
||||
useEffect(() => {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}, [trims, selectedMake, selectedModel, watchedYear, setValue, 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 <span className="text-red-500">*</span>
|
||||
VIN or License Plate <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
{...register('vin')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="Enter 17-character VIN"
|
||||
/>
|
||||
<div className="flex 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"
|
||||
placeholder="Enter 17-character VIN (optional if License Plate provided)"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleDecodeVIN}
|
||||
loading={decodingVIN}
|
||||
disabled={!watchedVIN || watchedVIN.length !== 17}
|
||||
variant="secondary"
|
||||
>
|
||||
Decode
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* Vehicle Specification Dropdowns */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid 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 })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<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
|
||||
@@ -131,7 +318,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
<select
|
||||
{...register('make')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
disabled={loadingDropdowns}
|
||||
disabled={loadingDropdowns || !watchedYear}
|
||||
>
|
||||
<option value="">Select Make</option>
|
||||
{makes.map((make) => (
|
||||
@@ -149,7 +336,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
<select
|
||||
{...register('model')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
disabled={!watchedMake || models.length === 0}
|
||||
disabled={loadingDropdowns || !watchedMake || models.length === 0}
|
||||
>
|
||||
<option value="">Select Model</option>
|
||||
{models.map((model) => (
|
||||
@@ -162,6 +349,26 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="grid 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')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
disabled={loadingDropdowns || !watchedModel || trims.length === 0}
|
||||
>
|
||||
<option value="">Select Trim</option>
|
||||
{trims.map((trim) => (
|
||||
<option key={trim.id} value={trim.name}>
|
||||
{trim.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Engine (middle) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Engine
|
||||
@@ -169,7 +376,7 @@ 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"
|
||||
disabled={loadingDropdowns}
|
||||
disabled={loadingDropdowns || !watchedModel || !selectedTrim || engines.length === 0}
|
||||
>
|
||||
<option value="">Select Engine</option>
|
||||
{engines.map((engine) => (
|
||||
@@ -180,6 +387,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Transmission (right, static options) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Transmission
|
||||
@@ -187,32 +395,10 @@ 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"
|
||||
disabled={loadingDropdowns}
|
||||
>
|
||||
<option value="">Select Transmission</option>
|
||||
{transmissions.map((transmission) => (
|
||||
<option key={transmission.id} value={transmission.name}>
|
||||
{transmission.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Trim Level
|
||||
</label>
|
||||
<select
|
||||
{...register('trimLevel')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
disabled={loadingDropdowns}
|
||||
>
|
||||
<option value="">Select Trim</option>
|
||||
{trims.map((trim) => (
|
||||
<option key={trim.id} value={trim.name}>
|
||||
{trim.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="Automatic">Automatic</option>
|
||||
<option value="Manual">Manual</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -247,8 +433,11 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
<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"
|
||||
placeholder="e.g., ABC-123"
|
||||
placeholder="e.g., ABC-123 (required if VIN omitted)"
|
||||
/>
|
||||
{errors.licensePlate && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.licensePlate.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -274,4 +463,4 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user