Vehicle drop down and Gas Station fixes
This commit is contained in:
@@ -11,5 +11,5 @@ export function getStationPhotoUrl(photoReference: string | undefined): string |
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `/api/stations/photo/${encodeURIComponent(photoReference)}`;
|
||||
return `/stations/photo/${encodeURIComponent(photoReference)}`;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,8 @@ export const vehiclesApi = {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const response = await apiClient.post(`/vehicles/${vehicleId}/image`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 60000 // 60 seconds for file uploads
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -61,6 +61,7 @@ interface VehicleFormProps {
|
||||
initialData?: Partial<CreateVehicleRequest> & { id?: string; imageUrl?: string };
|
||||
loading?: boolean;
|
||||
onImageUpdate?: (vehicle: Vehicle) => void;
|
||||
onStagedImage?: (file: File | null) => void;
|
||||
}
|
||||
|
||||
export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
@@ -69,6 +70,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
initialData,
|
||||
loading,
|
||||
onImageUpdate,
|
||||
onStagedImage,
|
||||
}) => {
|
||||
const [years, setYears] = useState<number[]>([]);
|
||||
const [makes, setMakes] = useState<string[]>([]);
|
||||
@@ -84,6 +86,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
const hasInitialized = useRef(false);
|
||||
const isInitializing = useRef(false);
|
||||
const [currentImageUrl, setCurrentImageUrl] = useState<string | undefined>(initialData?.imageUrl);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
|
||||
const isEditMode = !!initialData?.id;
|
||||
const vehicleId = initialData?.id;
|
||||
@@ -340,51 +343,69 @@ 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);
|
||||
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 (!vehicleId) return;
|
||||
await vehiclesApi.deleteImage(vehicleId);
|
||||
setCurrentImageUrl(undefined);
|
||||
if (initialData) {
|
||||
onImageUpdate?.({ ...initialData, imageUrl: undefined } as Vehicle);
|
||||
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: initialData?.make,
|
||||
make: currentMake,
|
||||
model: initialData?.model,
|
||||
year: initialData?.year,
|
||||
color: initialData?.color,
|
||||
color: watchedColor || initialData?.color,
|
||||
odometerReading: initialData?.odometerReading || 0,
|
||||
isActive: true,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
imageUrl: currentImageUrl,
|
||||
imageUrl: previewUrl || 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 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">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @ai-summary Vehicle image display with three-tier fallback
|
||||
* Tier 1: Custom uploaded image
|
||||
* Tier 1: Custom uploaded image (fetched with auth headers)
|
||||
* Tier 2: Make logo from /images/makes/
|
||||
* Tier 3: Color box placeholder
|
||||
*/
|
||||
@@ -8,6 +8,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { Vehicle } from '../types/vehicles.types';
|
||||
import { apiClient } from '../../../core/api/client';
|
||||
|
||||
interface VehicleImageProps {
|
||||
vehicle: Vehicle;
|
||||
@@ -28,21 +29,72 @@ export const VehicleImage: React.FC<VehicleImageProps> = ({
|
||||
}) => {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const [logoError, setLogoError] = useState(false);
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Fetch authenticated image and create blob URL
|
||||
useEffect(() => {
|
||||
setImgError(false);
|
||||
setLogoError(false);
|
||||
|
||||
if (!vehicle.imageUrl) {
|
||||
setBlobUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// If imageUrl is already a blob URL (from preview), use it directly
|
||||
if (vehicle.imageUrl.startsWith('blob:')) {
|
||||
setBlobUrl(vehicle.imageUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
|
||||
// Strip /api prefix if present since apiClient adds it
|
||||
const url = vehicle.imageUrl.startsWith('/api/')
|
||||
? vehicle.imageUrl.slice(4)
|
||||
: vehicle.imageUrl;
|
||||
|
||||
apiClient.get(url, { responseType: 'blob' })
|
||||
.then(response => {
|
||||
if (cancelled) return;
|
||||
const blobUrl = URL.createObjectURL(response.data);
|
||||
setBlobUrl(blobUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setImgError(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [vehicle.id, vehicle.imageUrl]);
|
||||
|
||||
if (vehicle.imageUrl && !imgError) {
|
||||
// Clean up blob URL on unmount (only if we created it, not if it was passed in)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (blobUrl && vehicle.imageUrl !== blobUrl) {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
};
|
||||
}, [blobUrl, vehicle.imageUrl]);
|
||||
|
||||
if (vehicle.imageUrl && !imgError && (blobUrl || isLoading)) {
|
||||
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 sx={{ height, borderRadius, overflow: 'hidden', mb: 2, bgcolor: isLoading ? '#F2EAEA' : undefined }}>
|
||||
{blobUrl && (
|
||||
<img
|
||||
src={blobUrl}
|
||||
alt={`${vehicle.make || ''} ${vehicle.model || ''}`.trim() || 'Vehicle'}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,9 +17,12 @@ import { Card } from '../../../shared-minimal/components/Card';
|
||||
import { VehicleListSuspense, FormSuspense } from '../../../components/SuspenseWrappers';
|
||||
import { useAppStore } from '../../../core/store';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { vehiclesApi } from '../api/vehicles.api';
|
||||
|
||||
export const VehiclesPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: vehicles, isLoading } = useVehicles();
|
||||
const setSelectedVehicle = useAppStore(state => state.setSelectedVehicle);
|
||||
|
||||
@@ -42,6 +45,7 @@ export const VehiclesPage: React.FC = () => {
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [stagedImageFile, setStagedImageFile] = useState<File | null>(null);
|
||||
|
||||
// Update search vehicles when optimistic vehicles change
|
||||
useEffect(() => {
|
||||
@@ -64,7 +68,37 @@ export const VehiclesPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleCreateVehicle = async (data: any) => {
|
||||
await optimisticCreateVehicle(data);
|
||||
const newVehicle = await optimisticCreateVehicle(data);
|
||||
|
||||
console.log('[VehiclesPage] Vehicle created:', newVehicle?.id, 'stagedImageFile:', !!stagedImageFile);
|
||||
|
||||
// Upload staged image if one was selected during creation
|
||||
if (stagedImageFile && newVehicle?.id) {
|
||||
// Don't upload if ID is temporary (optimistic)
|
||||
if (newVehicle.id.startsWith('temp-')) {
|
||||
console.warn('[VehiclesPage] Cannot upload image - vehicle has temporary ID:', newVehicle.id);
|
||||
} else {
|
||||
try {
|
||||
console.log('[VehiclesPage] Uploading image for vehicle:', newVehicle.id);
|
||||
const updatedVehicle = await vehiclesApi.uploadImage(newVehicle.id, stagedImageFile);
|
||||
console.log('[VehiclesPage] Image uploaded, updated vehicle:', updatedVehicle);
|
||||
// Directly update the cache with the vehicle that has imageUrl
|
||||
queryClient.setQueryData(['vehicles'], (old: typeof vehicles) => {
|
||||
if (!old || !Array.isArray(old)) return old;
|
||||
return old.map(v => v.id === updatedVehicle.id ? updatedVehicle : v);
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('[VehiclesPage] Failed to upload vehicle image:', {
|
||||
error: err,
|
||||
message: err?.message,
|
||||
response: err?.response?.data,
|
||||
status: err?.response?.status
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setStagedImageFile(null);
|
||||
// Use transition for UI state change
|
||||
startTransition(() => {
|
||||
setShowForm(false);
|
||||
@@ -150,6 +184,7 @@ export const VehiclesPage: React.FC = () => {
|
||||
onSubmit={handleCreateVehicle}
|
||||
onCancel={() => startTransition(() => setShowForm(false))}
|
||||
loading={isOptimisticPending}
|
||||
onStagedImage={setStagedImageFile}
|
||||
/>
|
||||
</Card>
|
||||
</FormSuspense>
|
||||
|
||||
Reference in New Issue
Block a user