Files
motovaultpro/frontend/src/features/vehicles/components/VehicleForm.tsx
Eric Gullickson ffd8ecd1d0
All checks were successful
Deploy to Staging / Build Images (push) Successful in 4m34s
Deploy to Staging / Deploy to Staging (push) Successful in 37s
Deploy to Staging / Verify Staging (push) Successful in 6s
Deploy to Staging / Notify Staging Ready (push) Successful in 5s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
fix: OS Detection of theme removed.
2026-01-01 10:30:08 -06:00

695 lines
25 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, 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<CreateVehicleRequest> & { id?: string; imageUrl?: string };
loading?: boolean;
onImageUpdate?: (vehicle: Vehicle) => void;
onStagedImage?: (file: File | null) => void;
}
export const VehicleForm: React.FC<VehicleFormProps> = ({
onSubmit,
onCancel,
initialData,
loading,
onImageUpdate,
onStagedImage,
}) => {
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 [loadingDropdowns, setLoadingDropdowns] = useState(false);
const hasInitialized = useRef(false);
const isInitializing = useRef(false);
// Track previous values for cascade change detection (replaces useState)
const prevYear = useRef<number | undefined>(undefined);
const prevMake = useRef<string>('');
const prevModel = useRef<string>('');
const prevTrim = useRef<string>('');
const [currentImageUrl, setCurrentImageUrl] = useState<string | undefined>(initialData?.imageUrl);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const isEditMode = !!initialData?.id;
const vehicleId = initialData?.id;
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 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 (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-2">
Vehicle Photo
</label>
<VehicleImageUpload
vehicle={vehicleForImage}
onUpload={handleImageUpload}
onRemove={handleImageRemove}
disabled={loading || loadingDropdowns}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
VIN Number <span className="text-red-500">*</span>
</label>
<p className="text-xs text-gray-600 dark:text-titanio mb-2">
Enter vehicle VIN (optional)
</p>
<input
{...register('vin')}
className="w-full px-3 py-2 border rounded-md text-base bg-white text-gray-900 border-gray-300 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:placeholder-canna dark:focus:ring-abudhabi dark:focus:border-abudhabi"
placeholder="Enter 17-character VIN (optional if License Plate provided)"
style={{ fontSize: '16px' }}
/>
{errors.vin && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{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 dark:text-avus mb-1">
Year
</label>
<select
{...register('year', { valueAsNumber: true })}
value={watchedYear || ''}
onChange={(e) => {
const year = parseInt(e.target.value);
setValue('year', year);
}}
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi"
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 dark:text-avus mb-1">
Make
</label>
<select
{...register('make')}
value={watchedMake || ''}
onChange={(e) => {
setValue('make', e.target.value);
}}
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi"
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 dark:text-avus mb-1">
Model
</label>
<select
{...register('model')}
value={watchedModel || ''}
onChange={(e) => {
setValue('model', e.target.value);
}}
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi"
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 dark:text-avus mb-1">
Trim
</label>
<select
{...register('trimLevel')}
value={watchedTrim || ''}
onChange={(e) => {
setValue('trimLevel', e.target.value);
}}
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi"
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 dark:text-avus mb-1">
Engine
</label>
<select
{...register('engine')}
value={watchedEngine || ''}
onChange={(e) => {
setValue('engine', e.target.value);
}}
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi"
disabled={loadingDropdowns || !watchedTrim || engines.length === 0}
style={{ fontSize: '16px' }}
>
<option value="">
{loadingDropdowns
? 'Loading...'
: !watchedTrim
? '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 dark:text-avus mb-1">
Transmission
</label>
<select
{...register('transmission')}
value={watchedTransmission || ''}
onChange={(e) => {
setValue('transmission', e.target.value);
}}
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi"
disabled={loadingDropdowns || !watchedTrim || transmissions.length === 0}
style={{ fontSize: '16px' }}
>
<option value="">
{loadingDropdowns
? 'Loading...'
: !watchedTrim
? 'Select trim 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 dark:text-avus mb-1">
Nickname
</label>
<input
{...register('nickname')}
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:placeholder-canna dark:focus:ring-abudhabi dark:focus:border-abudhabi"
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 dark:text-avus mb-1">
Color
</label>
<input
{...register('color')}
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:placeholder-canna dark:focus:ring-abudhabi dark:focus:border-abudhabi"
placeholder="e.g., Blue"
style={{ fontSize: '16px' }}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
License Plate
</label>
<input
{...register('licensePlate')}
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:placeholder-canna dark:focus:ring-abudhabi dark:focus:border-abudhabi"
placeholder="e.g., ABC-123 (required if VIN omitted)"
style={{ fontSize: '16px' }}
/>
{errors.licensePlate && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.licensePlate.message}</p>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
Current Odometer Reading
</label>
<input
{...register('odometerReading', { valueAsNumber: true })}
type="number"
inputMode="numeric"
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:placeholder-canna dark:focus:ring-abudhabi dark:focus:border-abudhabi"
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>
);
};