Photos for vehicles
This commit is contained in:
@@ -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`;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
86
frontend/src/features/vehicles/components/VehicleImage.tsx
Normal file
86
frontend/src/features/vehicles/components/VehicleImage.tsx
Normal 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
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
144
frontend/src/features/vehicles/components/VehicleImageUpload.tsx
Normal file
144
frontend/src/features/vehicles/components/VehicleImageUpload.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface Vehicle {
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
export interface CreateVehicleRequest {
|
||||
|
||||
Reference in New Issue
Block a user