Initial Commit

This commit is contained in:
Eric Gullickson
2025-09-17 16:09:15 -05:00
parent 0cdb9803de
commit a052040e3a
373 changed files with 437090 additions and 6773 deletions

View File

@@ -3,18 +3,9 @@
*/
import { apiClient } from '../../../core/api/client';
import axios from 'axios';
import { Vehicle, CreateVehicleRequest, UpdateVehicleRequest, DropdownOption } from '../types/vehicles.types';
import { Vehicle, CreateVehicleRequest, UpdateVehicleRequest, DropdownOption, VINDecodeResponse } 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',
},
});
// All requests (including dropdowns) use authenticated apiClient
export const vehiclesApi = {
getAll: async (): Promise<Vehicle[]> => {
@@ -41,29 +32,40 @@ export const vehiclesApi = {
await apiClient.delete(`/vehicles/${id}`);
},
// Dropdown API methods (unauthenticated)
getMakes: async (): Promise<DropdownOption[]> => {
const response = await dropdownClient.get('/vehicles/dropdown/makes');
// Dropdown API methods (authenticated)
getYears: async (): Promise<number[]> => {
const response = await apiClient.get('/vehicles/dropdown/years');
return response.data;
},
getModels: async (make: string): Promise<DropdownOption[]> => {
const response = await dropdownClient.get(`/vehicles/dropdown/models/${encodeURIComponent(make)}`);
getMakes: async (year: number): Promise<DropdownOption[]> => {
const response = await apiClient.get(`/vehicles/dropdown/makes?year=${year}`);
return response.data;
},
getTransmissions: async (): Promise<DropdownOption[]> => {
const response = await dropdownClient.get('/vehicles/dropdown/transmissions');
getModels: async (year: number, makeId: number): Promise<DropdownOption[]> => {
const response = await apiClient.get(`/vehicles/dropdown/models?year=${year}&make_id=${makeId}`);
return response.data;
},
getEngines: async (): Promise<DropdownOption[]> => {
const response = await dropdownClient.get('/vehicles/dropdown/engines');
getTransmissions: async (year: number, makeId: number, modelId: number): Promise<DropdownOption[]> => {
const response = await apiClient.get(`/vehicles/dropdown/transmissions?year=${year}&make_id=${makeId}&model_id=${modelId}`);
return response.data;
},
getTrims: async (): Promise<DropdownOption[]> => {
const response = await dropdownClient.get('/vehicles/dropdown/trims');
getEngines: async (year: number, makeId: number, modelId: number, trimId: number): Promise<DropdownOption[]> => {
const response = await apiClient.get(`/vehicles/dropdown/engines?year=${year}&make_id=${makeId}&model_id=${modelId}&trim_id=${trimId}`);
return response.data;
},
};
getTrims: async (year: number, makeId: number, modelId: number): Promise<DropdownOption[]> => {
const response = await apiClient.get(`/vehicles/dropdown/trims?year=${year}&make_id=${makeId}&model_id=${modelId}`);
return response.data;
},
// VIN decode method
decodeVIN: async (vin: string): Promise<VINDecodeResponse> => {
const response = await apiClient.post('/vehicles/decode-vin', { vin });
return response.data;
},
};

View File

@@ -7,6 +7,7 @@ import { Card, CardContent, CardActionArea, Box, Typography, IconButton } from '
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import { Vehicle } from '../types/vehicles.types';
import { useUnits } from '../../../core/units/UnitsContext';
interface VehicleCardProps {
vehicle: Vehicle;
@@ -35,8 +36,9 @@ export const VehicleCard: React.FC<VehicleCardProps> = ({
onDelete,
onSelect,
}) => {
const { formatDistance } = useUnits();
const displayName = vehicle.nickname ||
`${vehicle.year} ${vehicle.make} ${vehicle.model}`;
[vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean).join(' ');
return (
<Card
@@ -72,7 +74,7 @@ export const VehicleCard: React.FC<VehicleCardProps> = ({
)}
<Typography variant="body2" color="text.primary" sx={{ mt: 1, fontWeight: 500 }}>
Odometer: {vehicle.odometerReading.toLocaleString()} miles
Odometer: {formatDistance(vehicle.odometerReading)}
</Typography>
</CardContent>
</CardActionArea>

View File

@@ -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>
);
};
};

View File

@@ -45,7 +45,7 @@ export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
onLogFuel
}) => {
const displayName = vehicle.nickname ||
(vehicle.year && vehicle.make ? `${vehicle.year} ${vehicle.make}` : 'Vehicle');
[vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean).join(' ') || 'Vehicle';
const displayModel = vehicle.model || 'Unknown Model';
return (

View File

@@ -32,7 +32,7 @@ export const VehicleMobileCard: React.FC<VehicleMobileCardProps> = ({
compact = false
}) => {
const displayName = vehicle.nickname ||
(vehicle.year && vehicle.make ? `${vehicle.year} ${vehicle.make}` : 'Vehicle');
[vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean).join(' ') || 'Vehicle';
const displayModel = vehicle.model || 'Unknown Model';
return (

View File

@@ -4,7 +4,8 @@
*/
import React, { useTransition, useEffect } from 'react';
import { Box, Typography, Grid } from '@mui/material';
import { Box, Typography, Grid, Fab } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import { useVehicles } from '../hooks/useVehicles';
import { useOptimisticVehicles } from '../hooks/useOptimisticVehicles';
import { useVehicleSearch } from '../hooks/useVehicleTransitions';
@@ -14,6 +15,7 @@ import { Vehicle } from '../types/vehicles.types';
interface VehiclesMobileScreenProps {
onVehicleSelect?: (vehicle: Vehicle) => void;
onAddVehicle?: () => void;
}
const Section: React.FC<{ title: string; children: React.ReactNode; right?: React.ReactNode }> = ({
@@ -33,7 +35,8 @@ const Section: React.FC<{ title: string; children: React.ReactNode; right?: Reac
);
export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
onVehicleSelect
onVehicleSelect,
onAddVehicle
}) => {
const { data: vehicles, isLoading } = useVehicles();
const [_isPending, startTransition] = useTransition();
@@ -66,7 +69,12 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
return (
<Box sx={{ pb: 10 }}>
<Box sx={{ textAlign: 'center', py: 12 }}>
<Typography color="text.secondary">Loading vehicles...</Typography>
<Typography color="text.secondary" sx={{ mb: 2 }}>
Loading your vehicles...
</Typography>
<Typography variant="caption" color="text.secondary">
Please wait a moment
</Typography>
</Box>
</Box>
);
@@ -74,7 +82,7 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
if (!optimisticVehicles.length) {
return (
<Box sx={{ pb: 10 }}>
<Box sx={{ pb: 10, position: 'relative' }}>
<Section title="Vehicles">
<Box sx={{ textAlign: 'center', py: 12 }}>
<Typography color="text.secondary" sx={{ mb: 2 }}>
@@ -85,13 +93,27 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
</Typography>
</Box>
</Section>
{/* Floating Action Button */}
<Fab
color="primary"
sx={{
position: 'fixed',
bottom: 80, // Above bottom navigation
right: 16,
zIndex: 1000
}}
onClick={() => onAddVehicle?.()}
>
<AddIcon />
</Fab>
</Box>
);
}
return (
<MobileVehiclesSuspense>
<Box sx={{ pb: 10 }}>
<Box sx={{ pb: 10, position: 'relative' }}>
<Section title={`Vehicles ${isOptimisticPending ? '(Updating...)' : ''}`}>
<Grid container spacing={2}>
{filteredVehicles.map((vehicle) => (
@@ -104,6 +126,20 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
))}
</Grid>
</Section>
{/* Floating Action Button */}
<Fab
color="primary"
sx={{
position: 'fixed',
bottom: 80, // Above bottom navigation
right: 16,
zIndex: 1000
}}
onClick={() => onAddVehicle?.()}
>
<AddIcon />
</Fab>
</Box>
</MobileVehiclesSuspense>
);

View File

@@ -0,0 +1,255 @@
/**
* @ai-summary Vehicle detail page matching VehicleForm styling
*/
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Box, Typography, Button as MuiButton, Divider } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import EditIcon from '@mui/icons-material/Edit';
import LocalGasStationIcon from '@mui/icons-material/LocalGasStation';
import BuildIcon from '@mui/icons-material/Build';
import { Vehicle } from '../types/vehicles.types';
import { vehiclesApi } from '../api/vehicles.api';
import { Card } from '../../../shared-minimal/components/Card';
import { VehicleForm } from '../components/VehicleForm';
const DetailField: React.FC<{
label: string;
value?: string | number;
isRequired?: boolean;
className?: string;
}> = ({ label, value, isRequired, className = "" }) => (
<div className={`space-y-1 ${className}`}>
<label className="block text-sm font-medium text-gray-700">
{label} {isRequired && <span className="text-red-500">*</span>}
</label>
<div className="px-3 py-2 bg-gray-50 border border-gray-200 rounded-md">
<span className="text-gray-900">
{value || <span className="text-gray-400 italic">Not provided</span>}
</span>
</div>
</div>
);
export const VehicleDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [vehicle, setVehicle] = useState<Vehicle | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isEditing, setIsEditing] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadVehicle = async () => {
if (!id) return;
try {
setIsLoading(true);
const vehicleData = await vehiclesApi.getById(id);
setVehicle(vehicleData);
} catch (err) {
setError('Failed to load vehicle details');
console.error('Error loading vehicle:', err);
} finally {
setIsLoading(false);
}
};
loadVehicle();
}, [id]);
const handleBack = () => {
navigate('/vehicles');
};
const handleEdit = () => {
setIsEditing(true);
};
const handleUpdateVehicle = async (data: any) => {
if (!vehicle) return;
try {
const updatedVehicle = await vehiclesApi.update(vehicle.id, data);
setVehicle(updatedVehicle);
setIsEditing(false);
} catch (err) {
console.error('Error updating vehicle:', err);
}
};
const handleCancelEdit = () => {
setIsEditing(false);
};
if (isLoading) {
return (
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '50vh'
}}>
<Typography color="text.secondary">Loading vehicle details...</Typography>
</Box>
);
}
if (error || !vehicle) {
return (
<Box sx={{ py: 2 }}>
<Card>
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography color="error.main" sx={{ mb: 3 }}>
{error || 'Vehicle not found'}
</Typography>
<MuiButton
variant="outlined"
onClick={handleBack}
startIcon={<ArrowBackIcon />}
>
Back to Vehicles
</MuiButton>
</Box>
</Card>
</Box>
);
}
const displayName = vehicle.nickname ||
[vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean).join(' ') || 'Vehicle';
if (isEditing) {
return (
<Box sx={{ py: 2 }}>
<Box sx={{
display: 'flex',
alignItems: 'center',
mb: 4
}}>
<MuiButton
variant="text"
startIcon={<ArrowBackIcon />}
onClick={handleCancelEdit}
sx={{ mr: 2 }}
>
Cancel
</MuiButton>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary' }}>
Edit {displayName}
</Typography>
</Box>
<Card>
<VehicleForm
initialData={vehicle}
onSubmit={handleUpdateVehicle}
onCancel={handleCancelEdit}
/>
</Card>
</Box>
);
}
return (
<Box sx={{ py: 2 }}>
<Box sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 4
}}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<MuiButton
variant="text"
startIcon={<ArrowBackIcon />}
onClick={handleBack}
sx={{ mr: 2 }}
>
Back
</MuiButton>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary' }}>
{displayName}
</Typography>
</Box>
<MuiButton
variant="contained"
startIcon={<EditIcon />}
onClick={handleEdit}
sx={{ borderRadius: '999px' }}
>
Edit Vehicle
</MuiButton>
</Box>
<Box sx={{ display: 'flex', gap: 2, mb: 4 }}>
<MuiButton
variant="contained"
startIcon={<LocalGasStationIcon />}
sx={{ borderRadius: '999px' }}
>
Add Fuel Log
</MuiButton>
<MuiButton
variant="outlined"
startIcon={<BuildIcon />}
sx={{ borderRadius: '999px' }}
>
Schedule Maintenance
</MuiButton>
</Box>
<Card>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
Vehicle Details
</Typography>
<form className="space-y-4">
<DetailField
label="VIN or License Plate"
value={vehicle.vin || vehicle.licensePlate}
isRequired
/>
{/* Vehicle Specification Section */}
<div className="grid grid-cols-3 gap-4">
<DetailField label="Year" value={vehicle.year} />
<DetailField label="Make" value={vehicle.make} />
<DetailField label="Model" value={vehicle.model} />
</div>
<div className="grid grid-cols-3 gap-4">
<DetailField label="Trim" value={vehicle.trimLevel} />
<DetailField label="Engine" value={vehicle.engine} />
<DetailField label="Transmission" value={vehicle.transmission} />
</div>
<DetailField label="Nickname" value={vehicle.nickname} />
<div className="grid grid-cols-2 gap-4">
<DetailField label="Color" value={vehicle.color} />
<DetailField label="License Plate" value={vehicle.licensePlate} />
</div>
<DetailField
label="Current Odometer Reading"
value={vehicle.odometerReading ? `${vehicle.odometerReading.toLocaleString()} mi` : undefined}
/>
</form>
<Divider sx={{ my: 4 }} />
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
Vehicle Information
</Typography>
<Box sx={{ display: 'flex', gap: 4, color: 'text.secondary', fontSize: '0.875rem' }}>
<span>Added: {new Date(vehicle.createdAt).toLocaleDateString()}</span>
{vehicle.updatedAt !== vehicle.createdAt && (
<span>Last updated: {new Date(vehicle.updatedAt).toLocaleDateString()}</span>
)}
</Box>
</Card>
</Box>
);
};

View File

@@ -51,7 +51,8 @@ export const VehiclesPage: React.FC = () => {
const handleSelectVehicle = (id: string) => {
// Use transition for navigation to avoid blocking UI
startTransition(() => {
setSelectedVehicle(id);
const vehicle = optimisticVehicles.find(v => v.id === id);
setSelectedVehicle(vehicle || null);
navigate(`/vehicles/${id}`);
});
};

View File

@@ -25,6 +25,7 @@ export interface Vehicle {
export interface CreateVehicleRequest {
vin: string;
year?: number;
make?: string;
model?: string;
engine?: string;
@@ -55,4 +56,17 @@ export interface UpdateVehicleRequest {
export interface DropdownOption {
id: number;
name: string;
}
export interface VINDecodeResponse {
vin: string;
success: boolean;
year?: number;
make?: string;
model?: string;
trimLevel?: string;
engine?: string;
transmission?: string;
confidence?: number;
error?: string;
}