Photos for vehicles

This commit is contained in:
Eric Gullickson
2025-12-15 21:39:51 -06:00
parent e1c48b7a26
commit 263fc434b0
17 changed files with 745 additions and 58 deletions

View File

@@ -61,5 +61,22 @@ export const vehiclesApi = {
getTrims: async (year: number, make: string, model: string): Promise<string[]> => {
const response = await apiClient.get(`/vehicles/dropdown/trims?year=${year}&make=${encodeURIComponent(make)}&model=${encodeURIComponent(model)}`);
return response.data;
},
uploadImage: async (vehicleId: string, file: File): Promise<Vehicle> => {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.post(`/vehicles/${vehicleId}/image`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
return response.data;
},
deleteImage: async (vehicleId: string): Promise<void> => {
await apiClient.delete(`/vehicles/${vehicleId}/image`);
},
getImageUrl: (vehicleId: string): string => {
return `/api/vehicles/${vehicleId}/image`;
}
};

View File

@@ -8,6 +8,7 @@ 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';
import { VehicleImage } from './VehicleImage';
interface VehicleCardProps {
vehicle: Vehicle;
@@ -16,20 +17,6 @@ interface VehicleCardProps {
onSelect: (id: string) => void;
}
const CarThumb: React.FC<{ color?: string }> = ({ color = "#F2EAEA" }) => (
<Box
sx={{
height: 96,
bgcolor: color,
borderRadius: 2,
mb: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
/>
);
export const VehicleCard: React.FC<VehicleCardProps> = ({
vehicle,
onEdit,
@@ -57,7 +44,7 @@ export const VehicleCard: React.FC<VehicleCardProps> = ({
sx={{ flexGrow: 1 }}
>
<CardContent>
<CarThumb color={vehicle.color || "#F2EAEA"} />
<VehicleImage vehicle={vehicle} height={96} />
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1 }}>
{displayName}

View File

@@ -7,8 +7,9 @@ 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 { CreateVehicleRequest, Vehicle } from '../types/vehicles.types';
import { vehiclesApi } from '../api/vehicles.api';
import { VehicleImageUpload } from './VehicleImageUpload';
const vehicleSchema = z
.object({
@@ -57,8 +58,9 @@ const vehicleSchema = z
interface VehicleFormProps {
onSubmit: (data: CreateVehicleRequest) => void;
onCancel: () => void;
initialData?: Partial<CreateVehicleRequest>;
initialData?: Partial<CreateVehicleRequest> & { id?: string; imageUrl?: string };
loading?: boolean;
onImageUpdate?: (vehicle: Vehicle) => void;
}
export const VehicleForm: React.FC<VehicleFormProps> = ({
@@ -66,6 +68,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
onCancel,
initialData,
loading,
onImageUpdate,
}) => {
const [years, setYears] = useState<number[]>([]);
const [makes, setMakes] = useState<string[]>([]);
@@ -80,6 +83,10 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
const [loadingDropdowns, setLoadingDropdowns] = useState(false);
const hasInitialized = useRef(false);
const isInitializing = useRef(false);
const [currentImageUrl, setCurrentImageUrl] = useState<string | undefined>(initialData?.imageUrl);
const isEditMode = !!initialData?.id;
const vehicleId = initialData?.id;
const {
register,
@@ -332,8 +339,53 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
}
}, [watchedYear, selectedMake, selectedModel, watch('trimLevel')]);
const handleImageUpload = async (file: File) => {
if (!vehicleId) return;
const updated = await vehiclesApi.uploadImage(vehicleId, file);
setCurrentImageUrl(updated.imageUrl);
onImageUpdate?.(updated);
};
const handleImageRemove = async () => {
if (!vehicleId) return;
await vehiclesApi.deleteImage(vehicleId);
setCurrentImageUrl(undefined);
if (initialData) {
onImageUpdate?.({ ...initialData, imageUrl: undefined } as Vehicle);
}
};
const vehicleForImage: Vehicle = {
id: vehicleId || '',
userId: '',
vin: initialData?.vin || '',
make: initialData?.make,
model: initialData?.model,
year: initialData?.year,
color: initialData?.color,
odometerReading: initialData?.odometerReading || 0,
isActive: true,
createdAt: '',
updatedAt: '',
imageUrl: currentImageUrl,
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{isEditMode && (
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 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 mb-1">
VIN or License Plate <span className="text-red-500">*</span>

View File

@@ -0,0 +1,86 @@
/**
* @ai-summary Vehicle image display with three-tier fallback
* Tier 1: Custom uploaded image
* Tier 2: Make logo from /images/makes/
* Tier 3: Color box placeholder
*/
import React, { useState, useEffect } from 'react';
import { Box } from '@mui/material';
import { Vehicle } from '../types/vehicles.types';
interface VehicleImageProps {
vehicle: Vehicle;
height?: number;
borderRadius?: number;
}
const getMakeLogoPath = (make?: string): string | null => {
if (!make) return null;
const normalized = make.toLowerCase().replace(/\s+/g, '-');
return `/images/makes/${normalized}.png`;
};
export const VehicleImage: React.FC<VehicleImageProps> = ({
vehicle,
height = 96,
borderRadius = 2
}) => {
const [imgError, setImgError] = useState(false);
const [logoError, setLogoError] = useState(false);
useEffect(() => {
setImgError(false);
setLogoError(false);
}, [vehicle.id, vehicle.imageUrl]);
if (vehicle.imageUrl && !imgError) {
return (
<Box sx={{ height, borderRadius, overflow: 'hidden', mb: 2 }}>
<img
src={vehicle.imageUrl}
alt={`${vehicle.make || ''} ${vehicle.model || ''}`.trim() || 'Vehicle'}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={() => setImgError(true)}
/>
</Box>
);
}
const logoPath = getMakeLogoPath(vehicle.make);
if (logoPath && !logoError) {
return (
<Box sx={{
height,
borderRadius,
bgcolor: '#F2EAEA',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: 2,
mb: 2
}}>
<img
src={logoPath}
alt={`${vehicle.make} logo`}
style={{ maxHeight: '70%', maxWidth: '70%', objectFit: 'contain' }}
onError={() => setLogoError(true)}
/>
</Box>
);
}
return (
<Box
sx={{
height,
bgcolor: vehicle.color || '#F2EAEA',
borderRadius,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mb: 2
}}
/>
);
};

View File

@@ -0,0 +1,144 @@
/**
* @ai-summary Vehicle image upload component with preview and validation
*/
import React, { useState, useRef, ChangeEvent } from 'react';
import { Box, Button, CircularProgress, IconButton, Typography } from '@mui/material';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import DeleteIcon from '@mui/icons-material/Delete';
import { Vehicle } from '../types/vehicles.types';
import { VehicleImage } from './VehicleImage';
interface VehicleImageUploadProps {
vehicle: Vehicle;
onUpload: (file: File) => Promise<void>;
onRemove: () => Promise<void>;
disabled?: boolean;
}
const MAX_FILE_SIZE = 5 * 1024 * 1024;
const ALLOWED_TYPES = ['image/jpeg', 'image/png'];
export const VehicleImageUpload: React.FC<VehicleImageUploadProps> = ({
vehicle,
onUpload,
onRemove,
disabled = false
}) => {
const [uploading, setUploading] = useState(false);
const [removing, setRemoving] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setError(null);
if (!ALLOWED_TYPES.includes(file.type)) {
setError('Please select a JPEG or PNG image');
return;
}
if (file.size > MAX_FILE_SIZE) {
setError('Image must be less than 5MB');
return;
}
setUploading(true);
try {
await onUpload(file);
} catch (err) {
setError(err instanceof Error ? err.message : 'Upload failed');
} finally {
setUploading(false);
if (inputRef.current) {
inputRef.current.value = '';
}
}
};
const handleRemove = async () => {
setError(null);
setRemoving(true);
try {
await onRemove();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to remove image');
} finally {
setRemoving(false);
}
};
const hasImage = !!vehicle.imageUrl;
return (
<Box>
<Box sx={{ position: 'relative' }}>
<VehicleImage vehicle={vehicle} height={120} />
{(uploading || removing) && (
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 16,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'rgba(0,0,0,0.5)',
borderRadius: 2
}}
>
<CircularProgress size={32} sx={{ color: 'white' }} />
</Box>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png"
onChange={handleFileSelect}
style={{ display: 'none' }}
disabled={disabled || uploading || removing}
/>
<Button
variant="outlined"
size="small"
startIcon={<CloudUploadIcon />}
onClick={() => inputRef.current?.click()}
disabled={disabled || uploading || removing}
>
{hasImage ? 'Change Photo' : 'Add Photo'}
</Button>
{hasImage && (
<IconButton
size="small"
onClick={handleRemove}
disabled={disabled || uploading || removing}
sx={{ color: 'error.main' }}
>
<DeleteIcon fontSize="small" />
</IconButton>
)}
</Box>
{error && (
<Typography variant="caption" color="error" sx={{ display: 'block', mt: 1 }}>
{error}
</Typography>
)}
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
JPEG or PNG, max 5MB
</Typography>
</Box>
);
};

View File

@@ -10,8 +10,7 @@ import { useFuelLogs } from '../../fuel-logs/hooks/useFuelLogs';
import { FuelLogResponse, UpdateFuelLogRequest } from '../../fuel-logs/types/fuel-logs.types';
import { FuelLogEditDialog } from '../../fuel-logs/components/FuelLogEditDialog';
import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api';
// Theme colors now defined in Tailwind config
import { VehicleImage } from '../components/VehicleImage';
interface VehicleDetailMobileProps {
vehicle: Vehicle;
@@ -31,19 +30,6 @@ const Section: React.FC<{ title: string; children: React.ReactNode }> = ({
</Box>
);
const CarThumb: React.FC<{ color?: string }> = ({ color = "#F2EAEA" }) => (
<Box
sx={{
height: 96,
bgcolor: color,
borderRadius: 3,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
/>
);
export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
vehicle,
onBack,
@@ -167,7 +153,7 @@ export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 3 }}>
<Box sx={{ width: 112 }}>
<CarThumb color={vehicle.color || "#F2EAEA"} />
<VehicleImage vehicle={vehicle} height={96} />
</Box>
<Box>
<Typography variant="h5" sx={{ fontWeight: 600 }}>

View File

@@ -5,6 +5,7 @@
import React from 'react';
import { Card, CardActionArea, Box, Typography } from '@mui/material';
import { Vehicle } from '../types/vehicles.types';
import { VehicleImage } from '../components/VehicleImage';
interface VehicleMobileCardProps {
vehicle: Vehicle;
@@ -12,20 +13,6 @@ interface VehicleMobileCardProps {
compact?: boolean;
}
const CarThumb: React.FC<{ color?: string }> = ({ color = "#F2EAEA" }) => (
<Box
sx={{
height: 120,
bgcolor: color,
borderRadius: 2,
mb: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
/>
);
export const VehicleMobileCard: React.FC<VehicleMobileCardProps> = ({
vehicle,
onClick,
@@ -46,7 +33,7 @@ export const VehicleMobileCard: React.FC<VehicleMobileCardProps> = ({
>
<CardActionArea onClick={() => onClick?.(vehicle)}>
<Box sx={{ p: 2 }}>
<CarThumb color={vehicle.color || "#F2EAEA"} />
<VehicleImage vehicle={vehicle} height={120} />
<Typography variant="h6" sx={{ fontWeight: 700, mb: 0.5 }}>
{displayName}
</Typography>

View File

@@ -14,6 +14,7 @@ import { Vehicle } from '../types/vehicles.types';
import { vehiclesApi } from '../api/vehicles.api';
import { Card } from '../../../shared-minimal/components/Card';
import { VehicleForm } from '../components/VehicleForm';
import { VehicleImage } from '../components/VehicleImage';
import { useFuelLogs } from '../../fuel-logs/hooks/useFuelLogs';
import { FuelLogResponse, UpdateFuelLogRequest } from '../../fuel-logs/types/fuel-logs.types';
import { FuelLogEditDialog } from '../../fuel-logs/components/FuelLogEditDialog';
@@ -231,6 +232,7 @@ export const VehicleDetailPage: React.FC = () => {
initialData={vehicle}
onSubmit={handleUpdateVehicle}
onCancel={handleCancelEdit}
onImageUpdate={(updated) => setVehicle(updated)}
/>
</Card>
</Box>
@@ -287,10 +289,26 @@ export const VehicleDetailPage: React.FC = () => {
</Box>
<Card>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
Vehicle Details
</Typography>
<Box sx={{ display: 'flex', gap: 3, mb: 3 }}>
<Box sx={{ width: 200, flexShrink: 0 }}>
<VehicleImage vehicle={vehicle} height={150} />
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
Vehicle Details
</Typography>
<Typography variant="body2" color="text.secondary">
{vehicle.year} {vehicle.make} {vehicle.model}
{vehicle.trimLevel && ` ${vehicle.trimLevel}`}
</Typography>
{vehicle.vin && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
VIN: {vehicle.vin}
</Typography>
)}
</Box>
</Box>
<form className="space-y-4">
<DetailField
label="VIN or License Plate"

View File

@@ -21,6 +21,7 @@ export interface Vehicle {
isActive: boolean;
createdAt: string;
updatedAt: string;
imageUrl?: string;
}
export interface CreateVehicleRequest {