Vehicle drop down and Gas Station fixes
@@ -1,331 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Colors for output
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
echo -e "${YELLOW}=== MotoVaultPro ETL Plan V1 - Automated Application ===${NC}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Get script directory
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
cd "$SCRIPT_DIR"
|
|
||||||
|
|
||||||
# Backup directory
|
|
||||||
BACKUP_DIR="$SCRIPT_DIR/.etl-plan-backup-$(date +%Y%m%d-%H%M%S)"
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
|
||||||
|
|
||||||
echo -e "${GREEN}[1/4] Backing up files...${NC}"
|
|
||||||
# Backup ETL files
|
|
||||||
mkdir -p "$BACKUP_DIR/data/vehicle-etl"
|
|
||||||
cp data/vehicle-etl/vehapi_fetch_snapshot.py "$BACKUP_DIR/data/vehicle-etl/" 2>/dev/null || true
|
|
||||||
cp data/vehicle-etl/qa_validate.py "$BACKUP_DIR/data/vehicle-etl/" 2>/dev/null || true
|
|
||||||
|
|
||||||
# Backup backend files
|
|
||||||
mkdir -p "$BACKUP_DIR/backend/src/features/vehicles/api"
|
|
||||||
mkdir -p "$BACKUP_DIR/backend/src/features/vehicles/domain"
|
|
||||||
mkdir -p "$BACKUP_DIR/backend/src/features/platform/api"
|
|
||||||
mkdir -p "$BACKUP_DIR/backend/src/features/platform/domain"
|
|
||||||
mkdir -p "$BACKUP_DIR/backend/src/features/platform/data"
|
|
||||||
cp backend/src/features/vehicles/api/vehicles.controller.ts "$BACKUP_DIR/backend/src/features/vehicles/api/" 2>/dev/null || true
|
|
||||||
cp backend/src/features/vehicles/api/vehicles.routes.ts "$BACKUP_DIR/backend/src/features/vehicles/api/" 2>/dev/null || true
|
|
||||||
cp backend/src/features/vehicles/domain/vehicles.service.ts "$BACKUP_DIR/backend/src/features/vehicles/domain/" 2>/dev/null || true
|
|
||||||
cp backend/src/features/platform/api/platform.controller.ts "$BACKUP_DIR/backend/src/features/platform/api/" 2>/dev/null || true
|
|
||||||
cp backend/src/features/platform/index.ts "$BACKUP_DIR/backend/src/features/platform/" 2>/dev/null || true
|
|
||||||
cp backend/src/features/platform/domain/vin-decode.service.ts "$BACKUP_DIR/backend/src/features/platform/domain/" 2>/dev/null || true
|
|
||||||
cp backend/src/features/platform/data/vpic-client.ts "$BACKUP_DIR/backend/src/features/platform/data/" 2>/dev/null || true
|
|
||||||
|
|
||||||
# Backup frontend files
|
|
||||||
mkdir -p "$BACKUP_DIR/frontend/src/features/vehicles/components"
|
|
||||||
mkdir -p "$BACKUP_DIR/frontend/src/features/vehicles/api"
|
|
||||||
mkdir -p "$BACKUP_DIR/frontend/src/features/vehicles/types"
|
|
||||||
cp frontend/src/features/vehicles/components/VehicleForm.tsx "$BACKUP_DIR/frontend/src/features/vehicles/components/" 2>/dev/null || true
|
|
||||||
cp frontend/src/features/vehicles/api/vehicles.api.ts "$BACKUP_DIR/frontend/src/features/vehicles/api/" 2>/dev/null || true
|
|
||||||
cp frontend/src/features/vehicles/types/vehicles.types.ts "$BACKUP_DIR/frontend/src/features/vehicles/types/" 2>/dev/null || true
|
|
||||||
|
|
||||||
echo -e "${GREEN} Backup created at: $BACKUP_DIR${NC}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo -e "${GREEN}[2/4] Applying ETL Configuration Changes...${NC}"
|
|
||||||
|
|
||||||
# Update vehapi_fetch_snapshot.py
|
|
||||||
if [ -f "data/vehicle-etl/vehapi_fetch_snapshot.py" ]; then
|
|
||||||
echo " - Updating DEFAULT_MIN_YEAR from 1980 to 2017..."
|
|
||||||
sed -i.bak 's/DEFAULT_MIN_YEAR = 1980/DEFAULT_MIN_YEAR = 2017/g' data/vehicle-etl/vehapi_fetch_snapshot.py
|
|
||||||
sed -i.bak 's/default env MIN_YEAR or 1980/default env MIN_YEAR or 2017/g' data/vehicle-etl/vehapi_fetch_snapshot.py
|
|
||||||
rm data/vehicle-etl/vehapi_fetch_snapshot.py.bak
|
|
||||||
echo -e " ${GREEN}✓${NC} vehapi_fetch_snapshot.py updated"
|
|
||||||
else
|
|
||||||
echo -e " ${RED}✗${NC} vehapi_fetch_snapshot.py not found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Update qa_validate.py
|
|
||||||
if [ -f "data/vehicle-etl/qa_validate.py" ]; then
|
|
||||||
echo " - Updating year range validation from 1980-2022 to 2017-2022..."
|
|
||||||
sed -i.bak 's/year < 1980 OR year > 2022/year < 2017 OR year > 2022/g' data/vehicle-etl/qa_validate.py
|
|
||||||
rm data/vehicle-etl/qa_validate.py.bak
|
|
||||||
echo -e " ${GREEN}✓${NC} qa_validate.py updated"
|
|
||||||
else
|
|
||||||
echo -e " ${RED}✗${NC} qa_validate.py not found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Delete old ETL documentation
|
|
||||||
echo " - Removing old ETL documentation..."
|
|
||||||
rm -f data/vehicle-etl/ETL-FIX-V2.md
|
|
||||||
rm -f data/vehicle-etl/ETL-FIXES.md
|
|
||||||
rm -f data/vehicle-etl/ETL-VEHAPI-PLAN.md
|
|
||||||
rm -f data/vehicle-etl/ETL-VEHAPI-REDESIGN.md
|
|
||||||
echo -e " ${GREEN}✓${NC} Old ETL documentation removed"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo -e "${GREEN}[3/4] Applying Backend Changes...${NC}"
|
|
||||||
|
|
||||||
# Update vehicles.controller.ts MIN_YEAR
|
|
||||||
if [ -f "backend/src/features/vehicles/api/vehicles.controller.ts" ]; then
|
|
||||||
echo " - Updating MIN_YEAR from 1980 to 2017..."
|
|
||||||
sed -i.bak 's/private static readonly MIN_YEAR = 1980;/private static readonly MIN_YEAR = 2017;/g' backend/src/features/vehicles/api/vehicles.controller.ts
|
|
||||||
rm backend/src/features/vehicles/api/vehicles.controller.ts.bak
|
|
||||||
echo -e " ${GREEN}✓${NC} vehicles.controller.ts MIN_YEAR updated"
|
|
||||||
else
|
|
||||||
echo -e " ${RED}✗${NC} vehicles.controller.ts not found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Remove VIN decode route from vehicles.routes.ts
|
|
||||||
if [ -f "backend/src/features/vehicles/api/vehicles.routes.ts" ]; then
|
|
||||||
echo " - Removing VIN decode route..."
|
|
||||||
python3 << 'PYTHON_EOF'
|
|
||||||
import re
|
|
||||||
with open('backend/src/features/vehicles/api/vehicles.routes.ts', 'r') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Remove the decode-vin route (multi-line pattern)
|
|
||||||
pattern = r"\n\s*fastify\.post<\{ Body: \{ vin: string \} \}>\('/vehicles/decode-vin',\s*\{[^}]*\}\);"
|
|
||||||
content = re.sub(pattern, '', content, flags=re.DOTALL)
|
|
||||||
|
|
||||||
with open('backend/src/features/vehicles/api/vehicles.routes.ts', 'w') as f:
|
|
||||||
f.write(content)
|
|
||||||
PYTHON_EOF
|
|
||||||
echo -e " ${GREEN}✓${NC} VIN decode route removed from vehicles.routes.ts"
|
|
||||||
else
|
|
||||||
echo -e " ${YELLOW}⚠${NC} vehicles.routes.ts not found (may not exist)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Remove VIN decode from vehicles.service.ts
|
|
||||||
if [ -f "backend/src/features/vehicles/domain/vehicles.service.ts" ]; then
|
|
||||||
echo " - Removing VIN decode logic from vehicles.service.ts..."
|
|
||||||
python3 << 'PYTHON_EOF'
|
|
||||||
import re
|
|
||||||
with open('backend/src/features/vehicles/domain/vehicles.service.ts', 'r') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Remove import statement for vin-decode
|
|
||||||
content = re.sub(r"import\s*\{[^}]*getVINDecodeService[^}]*\}[^;]*;?\n?", '', content)
|
|
||||||
content = re.sub(r"import\s*\{[^}]*VINDecodeService[^}]*\}[^;]*;?\n?", '', content)
|
|
||||||
|
|
||||||
# Remove decodeVIN method if it exists
|
|
||||||
pattern = r'\n\s*async decodeVIN\([^)]*\)[^{]*\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\}\n'
|
|
||||||
content = re.sub(pattern, '\n', content, flags=re.DOTALL)
|
|
||||||
|
|
||||||
# Remove VIN decode logic from createVehicle method (between specific comments or patterns)
|
|
||||||
# This is more conservative - just remove obvious decode blocks
|
|
||||||
content = re.sub(r'\n\s*// VIN decode logic.*?(?=\n\s*(?:const|return|if|//|$))', '', content, flags=re.DOTALL)
|
|
||||||
|
|
||||||
with open('backend/src/features/vehicles/domain/vehicles.service.ts', 'w') as f:
|
|
||||||
f.write(content)
|
|
||||||
PYTHON_EOF
|
|
||||||
echo -e " ${GREEN}✓${NC} VIN decode logic removed from vehicles.service.ts"
|
|
||||||
else
|
|
||||||
echo -e " ${RED}✗${NC} vehicles.service.ts not found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Remove VIN decode from platform.controller.ts
|
|
||||||
if [ -f "backend/src/features/platform/api/platform.controller.ts" ]; then
|
|
||||||
echo " - Removing VIN decode from platform.controller.ts..."
|
|
||||||
python3 << 'PYTHON_EOF'
|
|
||||||
import re
|
|
||||||
with open('backend/src/features/platform/api/platform.controller.ts', 'r') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Remove VINDecodeService import
|
|
||||||
content = re.sub(r"import\s*\{[^}]*VINDecodeService[^}]*\}[^;]*;?\n?", '', content)
|
|
||||||
|
|
||||||
# Remove VINDecodeService from constructor
|
|
||||||
content = re.sub(r',?\s*private\s+vinDecodeService:\s*VINDecodeService', '', content)
|
|
||||||
content = re.sub(r'private\s+vinDecodeService:\s*VINDecodeService\s*,?', '', content)
|
|
||||||
|
|
||||||
# Remove decodeVIN method
|
|
||||||
pattern = r'\n\s*async decodeVIN\([^)]*\)[^{]*\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\}\n'
|
|
||||||
content = re.sub(pattern, '\n', content, flags=re.DOTALL)
|
|
||||||
|
|
||||||
with open('backend/src/features/platform/api/platform.controller.ts', 'w') as f:
|
|
||||||
f.write(content)
|
|
||||||
PYTHON_EOF
|
|
||||||
echo -e " ${GREEN}✓${NC} VIN decode removed from platform.controller.ts"
|
|
||||||
else
|
|
||||||
echo -e " ${YELLOW}⚠${NC} platform.controller.ts not found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Remove VINDecodeService from platform/index.ts
|
|
||||||
if [ -f "backend/src/features/platform/index.ts" ]; then
|
|
||||||
echo " - Removing VINDecodeService from platform/index.ts..."
|
|
||||||
python3 << 'PYTHON_EOF'
|
|
||||||
import re
|
|
||||||
with open('backend/src/features/platform/index.ts', 'r') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Remove VINDecodeService import
|
|
||||||
content = re.sub(r"import\s*\{[^}]*VINDecodeService[^}]*\}[^;]*;?\n?", '', content)
|
|
||||||
|
|
||||||
# Remove VINDecodeService export
|
|
||||||
content = re.sub(r"export\s*\{[^}]*VINDecodeService[^}]*\}[^;]*;?\n?", '', content)
|
|
||||||
content = re.sub(r",\s*VINDecodeService\s*", '', content)
|
|
||||||
content = re.sub(r"VINDecodeService\s*,\s*", '', content)
|
|
||||||
|
|
||||||
# Remove getVINDecodeService function
|
|
||||||
pattern = r'\n\s*export\s+function\s+getVINDecodeService\([^)]*\)[^{]*\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\}\n'
|
|
||||||
content = re.sub(pattern, '\n', content, flags=re.DOTALL)
|
|
||||||
|
|
||||||
with open('backend/src/features/platform/index.ts', 'w') as f:
|
|
||||||
f.write(content)
|
|
||||||
PYTHON_EOF
|
|
||||||
echo -e " ${GREEN}✓${NC} VINDecodeService removed from platform/index.ts"
|
|
||||||
else
|
|
||||||
echo -e " ${YELLOW}⚠${NC} platform/index.ts not found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Delete VIN decode service files
|
|
||||||
echo " - Deleting VIN decode service files..."
|
|
||||||
rm -f backend/src/features/platform/domain/vin-decode.service.ts
|
|
||||||
rm -f backend/src/features/platform/data/vpic-client.ts
|
|
||||||
echo -e " ${GREEN}✓${NC} VIN decode service files deleted"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo -e "${GREEN}[4/4] Applying Frontend Changes...${NC}"
|
|
||||||
|
|
||||||
# Update VehicleForm.tsx
|
|
||||||
if [ -f "frontend/src/features/vehicles/components/VehicleForm.tsx" ]; then
|
|
||||||
echo " - Removing VIN decode UI from VehicleForm.tsx..."
|
|
||||||
python3 << 'PYTHON_EOF'
|
|
||||||
import re
|
|
||||||
with open('frontend/src/features/vehicles/components/VehicleForm.tsx', 'r') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Remove decodingVIN and decodeSuccess state variables
|
|
||||||
content = re.sub(r"\n\s*const \[decodingVIN, setDecodingVIN\] = useState\(false\);", '', content)
|
|
||||||
content = re.sub(r"\n\s*const \[decodeSuccess, setDecodeSuccess\] = useState\(false\);", '', content)
|
|
||||||
content = re.sub(r"\n\s*const \[decodeSuccess, setDecodeSuccess\] = useState<boolean \| null>\(null\);", '', content)
|
|
||||||
|
|
||||||
# Remove watchedVIN if it exists
|
|
||||||
content = re.sub(r"\n\s*const watchedVIN = watch\('vin'\);", '', content)
|
|
||||||
|
|
||||||
# Remove handleDecodeVIN function
|
|
||||||
pattern = r'\n\s*const handleDecodeVIN = async \(\) => \{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\};'
|
|
||||||
content = re.sub(pattern, '', content, flags=re.DOTALL)
|
|
||||||
|
|
||||||
# Update helper text
|
|
||||||
content = re.sub(
|
|
||||||
r'Enter VIN to auto-fill vehicle details OR manually select from dropdowns below',
|
|
||||||
'Enter vehicle VIN (optional)',
|
|
||||||
content
|
|
||||||
)
|
|
||||||
content = re.sub(
|
|
||||||
r'Enter VIN to auto-populate fields',
|
|
||||||
'Enter vehicle VIN (optional)',
|
|
||||||
content
|
|
||||||
)
|
|
||||||
|
|
||||||
# Remove the Decode VIN button and surrounding div
|
|
||||||
# Pattern 1: div with flex layout containing input and button
|
|
||||||
pattern1 = r'<div className="flex flex-col sm:flex-row gap-2">\s*<input\s+\{\.\.\.register\(\'vin\'\)\}[^>]*>\s*</input>\s*<Button[^>]*onClick=\{handleDecodeVIN\}[^>]*>.*?</Button>\s*</div>'
|
|
||||||
replacement1 = r'<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 text-base" placeholder="Enter 17-character VIN (optional if License Plate provided)" style={{ fontSize: \'16px\' }} />'
|
|
||||||
content = re.sub(pattern1, replacement1, content, flags=re.DOTALL)
|
|
||||||
|
|
||||||
# Pattern 2: self-closing input with button after
|
|
||||||
pattern2 = r'<input\s+\{\.\.\.register\(\'vin\'\)\}[^/]*/>\s*<Button[^>]*onClick=\{handleDecodeVIN\}[^>]*>.*?</Button>'
|
|
||||||
content = re.sub(pattern2, replacement1, content, flags=re.DOTALL)
|
|
||||||
|
|
||||||
# Remove decode success message
|
|
||||||
content = re.sub(r'\n\s*\{decodeSuccess && \([^)]*\)\}', '', content, flags=re.DOTALL)
|
|
||||||
content = re.sub(r'\n\s*<p className="mt-1 text-sm text-green-600">VIN decoded successfully.*?</p>', '', content, flags=re.DOTALL)
|
|
||||||
|
|
||||||
with open('frontend/src/features/vehicles/components/VehicleForm.tsx', 'w') as f:
|
|
||||||
f.write(content)
|
|
||||||
PYTHON_EOF
|
|
||||||
echo -e " ${GREEN}✓${NC} VIN decode UI removed from VehicleForm.tsx"
|
|
||||||
else
|
|
||||||
echo -e " ${RED}✗${NC} VehicleForm.tsx not found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Update vehicles.api.ts
|
|
||||||
if [ -f "frontend/src/features/vehicles/api/vehicles.api.ts" ]; then
|
|
||||||
echo " - Removing decodeVIN method from vehicles.api.ts..."
|
|
||||||
python3 << 'PYTHON_EOF'
|
|
||||||
import re
|
|
||||||
with open('frontend/src/features/vehicles/api/vehicles.api.ts', 'r') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Remove VINDecodeResponse from imports
|
|
||||||
content = re.sub(r',\s*VINDecodeResponse', '', content)
|
|
||||||
content = re.sub(r'VINDecodeResponse\s*,', '', content)
|
|
||||||
|
|
||||||
# Remove decodeVIN method
|
|
||||||
pattern = r'\n\s*decodeVIN:\s*async\s*\([^)]*\)[^{]*\{[^}]*\},?'
|
|
||||||
content = re.sub(pattern, '', content, flags=re.DOTALL)
|
|
||||||
|
|
||||||
# Remove trailing comma before closing brace if present
|
|
||||||
content = re.sub(r',(\s*\};?\s*$)', r'\1', content)
|
|
||||||
|
|
||||||
with open('frontend/src/features/vehicles/api/vehicles.api.ts', 'w') as f:
|
|
||||||
f.write(content)
|
|
||||||
PYTHON_EOF
|
|
||||||
echo -e " ${GREEN}✓${NC} decodeVIN method removed from vehicles.api.ts"
|
|
||||||
else
|
|
||||||
echo -e " ${RED}✗${NC} vehicles.api.ts not found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Update vehicles.types.ts
|
|
||||||
if [ -f "frontend/src/features/vehicles/types/vehicles.types.ts" ]; then
|
|
||||||
echo " - Removing VINDecodeResponse interface from vehicles.types.ts..."
|
|
||||||
python3 << 'PYTHON_EOF'
|
|
||||||
import re
|
|
||||||
with open('frontend/src/features/vehicles/types/vehicles.types.ts', 'r') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Remove VINDecodeResponse interface
|
|
||||||
pattern = r'\n\s*export interface VINDecodeResponse \{[^}]*\}\n?'
|
|
||||||
content = re.sub(pattern, '\n', content, flags=re.DOTALL)
|
|
||||||
|
|
||||||
with open('frontend/src/features/vehicles/types/vehicles.types.ts', 'w') as f:
|
|
||||||
f.write(content)
|
|
||||||
PYTHON_EOF
|
|
||||||
echo -e " ${GREEN}✓${NC} VINDecodeResponse interface removed from vehicles.types.ts"
|
|
||||||
else
|
|
||||||
echo -e " ${RED}✗${NC} vehicles.types.ts not found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e "${GREEN}=== Changes Applied Successfully ===${NC}"
|
|
||||||
echo ""
|
|
||||||
echo -e "${YELLOW}Verification Commands:${NC}"
|
|
||||||
echo ""
|
|
||||||
echo "1. ETL Configuration:"
|
|
||||||
echo " cd data/vehicle-etl && python3 -c \"import vehapi_fetch_snapshot; print(vehapi_fetch_snapshot.DEFAULT_MIN_YEAR)\""
|
|
||||||
echo " Expected output: 2017"
|
|
||||||
echo ""
|
|
||||||
echo "2. Backend TypeScript:"
|
|
||||||
echo " cd backend && npx tsc --noEmit"
|
|
||||||
echo " Expected: No errors"
|
|
||||||
echo ""
|
|
||||||
echo "3. Frontend TypeScript:"
|
|
||||||
echo " cd frontend && npx tsc --noEmit"
|
|
||||||
echo " Expected: No errors"
|
|
||||||
echo ""
|
|
||||||
echo -e "${YELLOW}Next Steps:${NC}"
|
|
||||||
echo "1. Run the verification commands above"
|
|
||||||
echo "2. If all checks pass, proceed with Agent 4 (ETL Execution)"
|
|
||||||
echo "3. If there are issues, restore from backup: $BACKUP_DIR"
|
|
||||||
echo ""
|
|
||||||
echo -e "${GREEN}Backup location: $BACKUP_DIR${NC}"
|
|
||||||
@@ -27,6 +27,7 @@ import { pool } from './core/config/database';
|
|||||||
async function buildApp(): Promise<FastifyInstance> {
|
async function buildApp(): Promise<FastifyInstance> {
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: false, // Use custom logging plugin instead
|
logger: false, // Use custom logging plugin instead
|
||||||
|
maxParamLength: 1000, // Required for long Google Maps photo references
|
||||||
});
|
});
|
||||||
|
|
||||||
// Core middleware plugins
|
// Core middleware plugins
|
||||||
|
|||||||
@@ -10,15 +10,12 @@ import { pool } from '../../../core/config/database';
|
|||||||
import { logger } from '../../../core/logging/logger';
|
import { logger } from '../../../core/logging/logger';
|
||||||
import { CreateVehicleBody, UpdateVehicleBody, VehicleParams } from '../domain/vehicles.types';
|
import { CreateVehicleBody, UpdateVehicleBody, VehicleParams } from '../domain/vehicles.types';
|
||||||
import { getStorageService } from '../../../core/storage/storage.service';
|
import { getStorageService } from '../../../core/storage/storage.service';
|
||||||
import { Transform, TransformCallback, Readable } from 'stream';
|
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import FileType from 'file-type';
|
import FileType from 'file-type';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
export class VehiclesController {
|
export class VehiclesController {
|
||||||
private vehiclesService: VehiclesService;
|
private vehiclesService: VehiclesService;
|
||||||
private static readonly MIN_YEAR = 2017;
|
|
||||||
private static readonly MAX_YEAR = 2022;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const repository = new VehiclesRepository(pool);
|
const repository = new VehiclesRepository(pool);
|
||||||
@@ -160,13 +157,13 @@ export class VehiclesController {
|
|||||||
async getDropdownMakes(request: FastifyRequest<{ Querystring: { year: number } }>, reply: FastifyReply) {
|
async getDropdownMakes(request: FastifyRequest<{ Querystring: { year: number } }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const { year } = request.query;
|
const { year } = request.query;
|
||||||
if (!year || year < VehiclesController.MIN_YEAR || year > VehiclesController.MAX_YEAR) {
|
if (!year || isNaN(year)) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: 'Bad Request',
|
error: 'Bad Request',
|
||||||
message: `Valid year parameter is required (${VehiclesController.MIN_YEAR}-${VehiclesController.MAX_YEAR})`
|
message: 'Valid year parameter is required'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const makes = await this.vehiclesService.getDropdownMakes(year);
|
const makes = await this.vehiclesService.getDropdownMakes(year);
|
||||||
return reply.code(200).send(makes);
|
return reply.code(200).send(makes);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -181,10 +178,10 @@ export class VehiclesController {
|
|||||||
async getDropdownModels(request: FastifyRequest<{ Querystring: { year: number; make: string } }>, reply: FastifyReply) {
|
async getDropdownModels(request: FastifyRequest<{ Querystring: { year: number; make: string } }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const { year, make } = request.query;
|
const { year, make } = request.query;
|
||||||
if (!year || !make || year < VehiclesController.MIN_YEAR || year > VehiclesController.MAX_YEAR || make.trim().length === 0) {
|
if (!year || isNaN(year) || !make || make.trim().length === 0) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: 'Bad Request',
|
error: 'Bad Request',
|
||||||
message: `Valid year and make parameters are required (${VehiclesController.MIN_YEAR}-${VehiclesController.MAX_YEAR})`
|
message: 'Valid year and make parameters are required'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,10 +199,10 @@ export class VehiclesController {
|
|||||||
async getDropdownTransmissions(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string; trim: string } }>, reply: FastifyReply) {
|
async getDropdownTransmissions(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string; trim: string } }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const { year, make, model, trim } = request.query;
|
const { year, make, model, trim } = request.query;
|
||||||
if (!year || !make || !model || !trim || year < VehiclesController.MIN_YEAR || year > VehiclesController.MAX_YEAR || make.trim().length === 0 || model.trim().length === 0 || trim.trim().length === 0) {
|
if (!year || isNaN(year) || !make || !model || !trim || make.trim().length === 0 || model.trim().length === 0 || trim.trim().length === 0) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: 'Bad Request',
|
error: 'Bad Request',
|
||||||
message: `Valid year, make, model, and trim parameters are required (${VehiclesController.MIN_YEAR}-${VehiclesController.MAX_YEAR})`
|
message: 'Valid year, make, model, and trim parameters are required'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,10 +220,10 @@ export class VehiclesController {
|
|||||||
async getDropdownEngines(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string; trim: string } }>, reply: FastifyReply) {
|
async getDropdownEngines(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string; trim: string } }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const { year, make, model, trim } = request.query;
|
const { year, make, model, trim } = request.query;
|
||||||
if (!year || !make || !model || !trim || year < VehiclesController.MIN_YEAR || year > VehiclesController.MAX_YEAR || make.trim().length === 0 || model.trim().length === 0 || trim.trim().length === 0) {
|
if (!year || isNaN(year) || !make || !model || !trim || make.trim().length === 0 || model.trim().length === 0 || trim.trim().length === 0) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: 'Bad Request',
|
error: 'Bad Request',
|
||||||
message: `Valid year, make, model, and trim parameters are required (${VehiclesController.MIN_YEAR}-${VehiclesController.MAX_YEAR})`
|
message: 'Valid year, make, model, and trim parameters are required'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,10 +241,10 @@ export class VehiclesController {
|
|||||||
async getDropdownTrims(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string } }>, reply: FastifyReply) {
|
async getDropdownTrims(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string } }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const { year, make, model } = request.query;
|
const { year, make, model } = request.query;
|
||||||
if (!year || !make || !model || year < VehiclesController.MIN_YEAR || year > VehiclesController.MAX_YEAR || make.trim().length === 0 || model.trim().length === 0) {
|
if (!year || isNaN(year) || !make || !model || make.trim().length === 0 || model.trim().length === 0) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: 'Bad Request',
|
error: 'Bad Request',
|
||||||
message: `Valid year, make, and model parameters are required (${VehiclesController.MIN_YEAR}-${VehiclesController.MAX_YEAR})`
|
message: 'Valid year, make, and model parameters are required'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,10 +276,10 @@ export class VehiclesController {
|
|||||||
async getDropdownOptions(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string; trim: string; engine?: string; transmission?: string } }>, reply: FastifyReply) {
|
async getDropdownOptions(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string; trim: string; engine?: string; transmission?: string } }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const { year, make, model, trim, engine, transmission } = request.query;
|
const { year, make, model, trim, engine, transmission } = request.query;
|
||||||
if (!year || !make || !model || !trim || year < VehiclesController.MIN_YEAR || year > VehiclesController.MAX_YEAR || make.trim().length === 0 || model.trim().length === 0 || trim.trim().length === 0) {
|
if (!year || isNaN(year) || !make || !model || !trim || make.trim().length === 0 || model.trim().length === 0 || trim.trim().length === 0) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: 'Bad Request',
|
error: 'Bad Request',
|
||||||
message: `Valid year, make, model, and trim parameters are required (${VehiclesController.MIN_YEAR}-${VehiclesController.MAX_YEAR})`
|
message: 'Valid year, make, model, and trim parameters are required'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,23 +347,16 @@ export class VehiclesController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read first 4100 bytes to detect file type via magic bytes
|
// Buffer the entire file for reliable processing
|
||||||
|
// Vehicle images are typically small (< 10MB) so this is safe
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
let totalBytes = 0;
|
|
||||||
const targetBytes = 4100;
|
|
||||||
|
|
||||||
for await (const chunk of mp.file) {
|
for await (const chunk of mp.file) {
|
||||||
chunks.push(chunk);
|
chunks.push(chunk);
|
||||||
totalBytes += chunk.length;
|
|
||||||
if (totalBytes >= targetBytes) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const fileBuffer = Buffer.concat(chunks);
|
||||||
const headerBuffer = Buffer.concat(chunks);
|
|
||||||
|
|
||||||
// Validate actual file content using magic bytes
|
// Validate actual file content using magic bytes
|
||||||
const detectedType = await FileType.fromBuffer(headerBuffer);
|
const detectedType = await FileType.fromBuffer(fileBuffer);
|
||||||
|
|
||||||
if (!detectedType) {
|
if (!detectedType) {
|
||||||
logger.warn('Unable to detect file type from content', {
|
logger.warn('Unable to detect file type from content', {
|
||||||
@@ -424,39 +414,20 @@ export class VehiclesController {
|
|||||||
const originalName: string = mp.filename || 'vehicle-image';
|
const originalName: string = mp.filename || 'vehicle-image';
|
||||||
const ext = contentType === 'image/jpeg' ? 'jpg' : 'png';
|
const ext = contentType === 'image/jpeg' ? 'jpg' : 'png';
|
||||||
|
|
||||||
class CountingStream extends Transform {
|
|
||||||
public bytes = 0;
|
|
||||||
override _transform(chunk: any, _enc: BufferEncoding, cb: TransformCallback) {
|
|
||||||
this.bytes += chunk.length || 0;
|
|
||||||
cb(null, chunk);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const counter = new CountingStream();
|
|
||||||
|
|
||||||
// Create a new readable stream from the header buffer + remaining file chunks
|
|
||||||
const headerStream = Readable.from([headerBuffer]);
|
|
||||||
const remainingStream = mp.file;
|
|
||||||
|
|
||||||
// Pipe header first, then remaining content through counter
|
|
||||||
headerStream.pipe(counter, { end: false });
|
|
||||||
headerStream.on('end', () => {
|
|
||||||
remainingStream.pipe(counter);
|
|
||||||
});
|
|
||||||
|
|
||||||
const storage = getStorageService();
|
const storage = getStorageService();
|
||||||
const bucket = 'vehicle-images';
|
const bucket = 'vehicle-images';
|
||||||
const unique = crypto.randomBytes(32).toString('hex');
|
const unique = crypto.randomBytes(32).toString('hex');
|
||||||
const key = `vehicle-images/${userId}/${vehicleId}/${unique}.${ext}`;
|
const key = `vehicle-images/${userId}/${vehicleId}/${unique}.${ext}`;
|
||||||
|
|
||||||
await storage.putObject(bucket, key, counter, contentType, { 'x-original-filename': originalName });
|
// Write buffer directly to storage
|
||||||
|
await storage.putObject(bucket, key, fileBuffer, contentType, { 'x-original-filename': originalName });
|
||||||
|
|
||||||
const updated = await this.vehiclesService.updateVehicleImage(vehicleId, userId, {
|
const updated = await this.vehiclesService.updateVehicleImage(vehicleId, userId, {
|
||||||
imageStorageBucket: bucket,
|
imageStorageBucket: bucket,
|
||||||
imageStorageKey: key,
|
imageStorageKey: key,
|
||||||
imageFileName: originalName,
|
imageFileName: originalName,
|
||||||
imageContentType: contentType,
|
imageContentType: contentType,
|
||||||
imageFileSize: counter.bytes,
|
imageFileSize: fileBuffer.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Vehicle image upload completed', {
|
logger.info('Vehicle image upload completed', {
|
||||||
@@ -466,7 +437,7 @@ export class VehiclesController {
|
|||||||
fileName: originalName,
|
fileName: originalName,
|
||||||
contentType,
|
contentType,
|
||||||
detectedType: detectedType.mime,
|
detectedType: detectedType.mime,
|
||||||
fileSize: counter.bytes,
|
fileSize: fileBuffer.length,
|
||||||
storageKey: key,
|
storageKey: key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 361 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"x-original-filename": "C7Z06-BlackRose.jpg",
|
||||||
|
"content-type": "image/jpeg"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 361 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"x-original-filename": "C7Z06-BlackRose.jpg",
|
||||||
|
"content-type": "image/jpeg"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 361 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"x-original-filename": "C7Z06-BlackRose.jpg",
|
||||||
|
"content-type": "image/jpeg"
|
||||||
|
}
|
||||||
@@ -2,19 +2,30 @@
|
|||||||
INSERT INTO transmissions (id, type) VALUES
|
INSERT INTO transmissions (id, type) VALUES
|
||||||
(1,'Automatic'),
|
(1,'Automatic'),
|
||||||
(2,'Manual'),
|
(2,'Manual'),
|
||||||
(3,'8-Speed Dual Clutch'),
|
(3,'3-Speed Automatic'),
|
||||||
(4,'9-Speed Automatic'),
|
(4,'5-Speed Manual'),
|
||||||
(5,'7-Speed Dual Clutch'),
|
(5,'4-Speed Manual'),
|
||||||
(6,'9-Speed Dual Clutch'),
|
(6,'3-Speed Manual'),
|
||||||
(7,'6-Speed Automatic'),
|
(7,'4-Speed Automatic'),
|
||||||
(8,'6-Speed Dual Clutch'),
|
(8,'6-Speed Manual'),
|
||||||
(9,'6-Speed Manual'),
|
(9,'4-Speed Automatic Overdrive'),
|
||||||
(10,'8-Speed Automatic'),
|
(10,'5-Speed Manual Overdrive'),
|
||||||
(11,'1-Speed Dual Clutch'),
|
(11,'Continuously Variable Transmission'),
|
||||||
(12,'6-Speed Automatic Overdrive'),
|
(12,'5-Speed Automatic'),
|
||||||
(13,'4-Speed Automatic'),
|
(13,'6-Speed Manual Overdrive'),
|
||||||
(14,'10-Speed Automatic'),
|
(14,'1-Speed Dual Clutch'),
|
||||||
(15,'Continuously Variable Transmission'),
|
(15,'5-Speed Automatic Overdrive'),
|
||||||
(16,'7-Speed Manual'),
|
(16,'6-Speed Automatic'),
|
||||||
(17,'5-Speed Manual');
|
(17,'6-Speed Automatic Overdrive'),
|
||||||
|
(18,'6-Speed CVT'),
|
||||||
|
(19,'7-Speed Automatic'),
|
||||||
|
(20,'6-Speed Dual Clutch'),
|
||||||
|
(21,'8-Speed Dual Clutch'),
|
||||||
|
(22,'9-Speed Automatic'),
|
||||||
|
(23,'7-Speed Dual Clutch'),
|
||||||
|
(24,'9-Speed Dual Clutch'),
|
||||||
|
(25,'8-Speed Automatic'),
|
||||||
|
(26,'10-Speed Automatic'),
|
||||||
|
(27,'7-Speed Manual'),
|
||||||
|
(28,'7-Speed CVT');
|
||||||
|
|
||||||
|
|||||||
@@ -18,15 +18,16 @@ Your task is to create a plan that can be dispatched to a seprate set of AI agen
|
|||||||
- - "Navigate in Wave" with a link to Waze
|
- - "Navigate in Wave" with a link to Waze
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
*** PERSONALITY ***
|
*** PERSONALITY ***
|
||||||
You are a senior application architect specializing in modern web applications. Your task is to create a plan to improve the admin settings screen for managing the vehicle catalog.
|
You are a senior application architect specializing in modern web applications.
|
||||||
Then read @docs/admin*.md files then read README.md CLAUDE.md and AI-INDEX.md to understand this code repository in the context of this change.
|
Read README.md CLAUDE.md and AI-INDEX.md to understand this code repository in the context of this change.
|
||||||
|
|
||||||
*** FEATURE ***
|
*** FEATURE ***
|
||||||
- Admin Settings feature. Specifically improvements in the speed and UX for editing the vehicle catalog.
|
- Vehicles feature. This will include all vehicle cards and details. The photo should display where ever possible that it own't distort the original image too much.
|
||||||
|
|
||||||
*** BUGS TO FIX ***
|
|
||||||
- Loading the data just spins right now. The amount of data overflows the client browser so the UX never shows.
|
|
||||||
|
|
||||||
*** CHANGES TO IMPLEMENT ***
|
*** CHANGES TO IMPLEMENT ***
|
||||||
- Recommend an improved workflow to create, update and delete vehicle catalog combinations.
|
Your task is to create a plan to improve the vehicles feature. You need to add the ability to upload a custom image of the vehicle. If there is no custom image uploaded, default it to the make logo which is located at @frontend/public/images/makes/. If the make photo does exist, default to a neutral color in gray.
|
||||||
|
|
||||||
|
*** Explore Agent ***
|
||||||
|
When you explore for this implementation, look to see if any work has already been done.
|
||||||
@@ -11,5 +11,5 @@ export function getStationPhotoUrl(photoReference: string | undefined): string |
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `/api/stations/photo/${encodeURIComponent(photoReference)}`;
|
return `/stations/photo/${encodeURIComponent(photoReference)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ export const vehiclesApi = {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
const response = await apiClient.post(`/vehicles/${vehicleId}/image`, formData, {
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ interface VehicleFormProps {
|
|||||||
initialData?: Partial<CreateVehicleRequest> & { id?: string; imageUrl?: string };
|
initialData?: Partial<CreateVehicleRequest> & { id?: string; imageUrl?: string };
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
onImageUpdate?: (vehicle: Vehicle) => void;
|
onImageUpdate?: (vehicle: Vehicle) => void;
|
||||||
|
onStagedImage?: (file: File | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VehicleForm: React.FC<VehicleFormProps> = ({
|
export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||||
@@ -69,6 +70,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|||||||
initialData,
|
initialData,
|
||||||
loading,
|
loading,
|
||||||
onImageUpdate,
|
onImageUpdate,
|
||||||
|
onStagedImage,
|
||||||
}) => {
|
}) => {
|
||||||
const [years, setYears] = useState<number[]>([]);
|
const [years, setYears] = useState<number[]>([]);
|
||||||
const [makes, setMakes] = useState<string[]>([]);
|
const [makes, setMakes] = useState<string[]>([]);
|
||||||
@@ -84,6 +86,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|||||||
const hasInitialized = useRef(false);
|
const hasInitialized = useRef(false);
|
||||||
const isInitializing = useRef(false);
|
const isInitializing = useRef(false);
|
||||||
const [currentImageUrl, setCurrentImageUrl] = useState<string | undefined>(initialData?.imageUrl);
|
const [currentImageUrl, setCurrentImageUrl] = useState<string | undefined>(initialData?.imageUrl);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
const isEditMode = !!initialData?.id;
|
const isEditMode = !!initialData?.id;
|
||||||
const vehicleId = initialData?.id;
|
const vehicleId = initialData?.id;
|
||||||
@@ -340,51 +343,69 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|||||||
}, [watchedYear, selectedMake, selectedModel, watch('trimLevel')]);
|
}, [watchedYear, selectedMake, selectedModel, watch('trimLevel')]);
|
||||||
|
|
||||||
const handleImageUpload = async (file: File) => {
|
const handleImageUpload = async (file: File) => {
|
||||||
if (!vehicleId) return;
|
if (isEditMode && vehicleId) {
|
||||||
const updated = await vehiclesApi.uploadImage(vehicleId, file);
|
// Edit mode: upload immediately to server
|
||||||
setCurrentImageUrl(updated.imageUrl);
|
const updated = await vehiclesApi.uploadImage(vehicleId, file);
|
||||||
onImageUpdate?.(updated);
|
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 () => {
|
const handleImageRemove = async () => {
|
||||||
if (!vehicleId) return;
|
if (isEditMode && vehicleId) {
|
||||||
await vehiclesApi.deleteImage(vehicleId);
|
// Edit mode: delete from server
|
||||||
setCurrentImageUrl(undefined);
|
await vehiclesApi.deleteImage(vehicleId);
|
||||||
if (initialData) {
|
setCurrentImageUrl(undefined);
|
||||||
onImageUpdate?.({ ...initialData, imageUrl: undefined } as Vehicle);
|
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 = {
|
const vehicleForImage: Vehicle = {
|
||||||
id: vehicleId || '',
|
id: vehicleId || '',
|
||||||
userId: '',
|
userId: '',
|
||||||
vin: initialData?.vin || '',
|
vin: initialData?.vin || '',
|
||||||
make: initialData?.make,
|
make: currentMake,
|
||||||
model: initialData?.model,
|
model: initialData?.model,
|
||||||
year: initialData?.year,
|
year: initialData?.year,
|
||||||
color: initialData?.color,
|
color: watchedColor || initialData?.color,
|
||||||
odometerReading: initialData?.odometerReading || 0,
|
odometerReading: initialData?.odometerReading || 0,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
updatedAt: '',
|
updatedAt: '',
|
||||||
imageUrl: currentImageUrl,
|
imageUrl: previewUrl || currentImageUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
{isEditMode && (
|
<div className="mb-6">
|
||||||
<div className="mb-6">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
Vehicle Photo
|
||||||
Vehicle Photo
|
</label>
|
||||||
</label>
|
<VehicleImageUpload
|
||||||
<VehicleImageUpload
|
vehicle={vehicleForImage}
|
||||||
vehicle={vehicleForImage}
|
onUpload={handleImageUpload}
|
||||||
onUpload={handleImageUpload}
|
onRemove={handleImageRemove}
|
||||||
onRemove={handleImageRemove}
|
disabled={loading || loadingDropdowns}
|
||||||
disabled={loading || loadingDropdowns}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<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
|
* @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 2: Make logo from /images/makes/
|
||||||
* Tier 3: Color box placeholder
|
* Tier 3: Color box placeholder
|
||||||
*/
|
*/
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Box } from '@mui/material';
|
import { Box } from '@mui/material';
|
||||||
import { Vehicle } from '../types/vehicles.types';
|
import { Vehicle } from '../types/vehicles.types';
|
||||||
|
import { apiClient } from '../../../core/api/client';
|
||||||
|
|
||||||
interface VehicleImageProps {
|
interface VehicleImageProps {
|
||||||
vehicle: Vehicle;
|
vehicle: Vehicle;
|
||||||
@@ -28,21 +29,72 @@ export const VehicleImage: React.FC<VehicleImageProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [imgError, setImgError] = useState(false);
|
const [imgError, setImgError] = useState(false);
|
||||||
const [logoError, setLogoError] = 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(() => {
|
useEffect(() => {
|
||||||
setImgError(false);
|
setImgError(false);
|
||||||
setLogoError(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]);
|
}, [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 (
|
return (
|
||||||
<Box sx={{ height, borderRadius, overflow: 'hidden', mb: 2 }}>
|
<Box sx={{ height, borderRadius, overflow: 'hidden', mb: 2, bgcolor: isLoading ? '#F2EAEA' : undefined }}>
|
||||||
<img
|
{blobUrl && (
|
||||||
src={vehicle.imageUrl}
|
<img
|
||||||
alt={`${vehicle.make || ''} ${vehicle.model || ''}`.trim() || 'Vehicle'}
|
src={blobUrl}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
alt={`${vehicle.make || ''} ${vehicle.model || ''}`.trim() || 'Vehicle'}
|
||||||
onError={() => setImgError(true)}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
/>
|
onError={() => setImgError(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,12 @@ import { Card } from '../../../shared-minimal/components/Card';
|
|||||||
import { VehicleListSuspense, FormSuspense } from '../../../components/SuspenseWrappers';
|
import { VehicleListSuspense, FormSuspense } from '../../../components/SuspenseWrappers';
|
||||||
import { useAppStore } from '../../../core/store';
|
import { useAppStore } from '../../../core/store';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { vehiclesApi } from '../api/vehicles.api';
|
||||||
|
|
||||||
export const VehiclesPage: React.FC = () => {
|
export const VehiclesPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { data: vehicles, isLoading } = useVehicles();
|
const { data: vehicles, isLoading } = useVehicles();
|
||||||
const setSelectedVehicle = useAppStore(state => state.setSelectedVehicle);
|
const setSelectedVehicle = useAppStore(state => state.setSelectedVehicle);
|
||||||
|
|
||||||
@@ -42,6 +45,7 @@ export const VehiclesPage: React.FC = () => {
|
|||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [stagedImageFile, setStagedImageFile] = useState<File | null>(null);
|
||||||
|
|
||||||
// Update search vehicles when optimistic vehicles change
|
// Update search vehicles when optimistic vehicles change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -64,7 +68,37 @@ export const VehiclesPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateVehicle = async (data: any) => {
|
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
|
// Use transition for UI state change
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
@@ -150,6 +184,7 @@ export const VehiclesPage: React.FC = () => {
|
|||||||
onSubmit={handleCreateVehicle}
|
onSubmit={handleCreateVehicle}
|
||||||
onCancel={() => startTransition(() => setShowForm(false))}
|
onCancel={() => startTransition(() => setShowForm(false))}
|
||||||
loading={isOptimisticPending}
|
loading={isOptimisticPending}
|
||||||
|
onStagedImage={setStagedImageFile}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</FormSuspense>
|
</FormSuspense>
|
||||||
|
|||||||