Vehicle drop down and Gas Station fixes

This commit is contained in:
Eric Gullickson
2025-12-17 10:49:29 -06:00
parent 0925a31fd4
commit cd0cfa8913
26 changed files with 133025 additions and 1779 deletions

View File

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

View File

@@ -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">

View File

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

View File

@@ -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>