Photos for vehicles
This commit is contained in:
@@ -9,6 +9,11 @@ import { VehiclesRepository } from '../data/vehicles.repository';
|
|||||||
import { pool } from '../../../core/config/database';
|
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 { Transform, TransformCallback, Readable } from 'stream';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import FileType from 'file-type';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
export class VehiclesController {
|
export class VehiclesController {
|
||||||
private vehiclesService: VehiclesService;
|
private vehiclesService: VehiclesService;
|
||||||
@@ -291,4 +296,300 @@ export class VehiclesController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async uploadImage(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
||||||
|
const userId = (request as any).user.sub;
|
||||||
|
const vehicleId = request.params.id;
|
||||||
|
|
||||||
|
logger.info('Vehicle image upload requested', {
|
||||||
|
operation: 'vehicles.uploadImage',
|
||||||
|
userId,
|
||||||
|
vehicleId,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify vehicle ownership
|
||||||
|
const vehicle = await this.vehiclesService.getVehicleRaw(vehicleId, userId);
|
||||||
|
if (!vehicle) {
|
||||||
|
logger.warn('Vehicle not found for image upload', {
|
||||||
|
operation: 'vehicles.uploadImage.not_found',
|
||||||
|
userId,
|
||||||
|
vehicleId,
|
||||||
|
});
|
||||||
|
return reply.code(404).send({ error: 'Not Found', message: 'Vehicle not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const mp = await (request as any).file({ limits: { files: 1 } });
|
||||||
|
if (!mp) {
|
||||||
|
logger.warn('No file provided for image upload', {
|
||||||
|
operation: 'vehicles.uploadImage.no_file',
|
||||||
|
userId,
|
||||||
|
vehicleId,
|
||||||
|
});
|
||||||
|
return reply.code(400).send({ error: 'Bad Request', message: 'No file provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowed image types
|
||||||
|
const allowedTypes = new Map([
|
||||||
|
['image/jpeg', new Set(['image/jpeg'])],
|
||||||
|
['image/png', new Set(['image/png'])],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const contentType = mp.mimetype as string | undefined;
|
||||||
|
if (!contentType || !allowedTypes.has(contentType)) {
|
||||||
|
logger.warn('Unsupported image type', {
|
||||||
|
operation: 'vehicles.uploadImage.unsupported_type',
|
||||||
|
userId,
|
||||||
|
vehicleId,
|
||||||
|
contentType,
|
||||||
|
fileName: mp.filename,
|
||||||
|
});
|
||||||
|
return reply.code(415).send({
|
||||||
|
error: 'Unsupported Media Type',
|
||||||
|
message: 'Only JPEG and PNG images are allowed'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read first 4100 bytes to detect file type via magic bytes
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let totalBytes = 0;
|
||||||
|
const targetBytes = 4100;
|
||||||
|
|
||||||
|
for await (const chunk of mp.file) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
totalBytes += chunk.length;
|
||||||
|
if (totalBytes >= targetBytes) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerBuffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
|
// Validate actual file content using magic bytes
|
||||||
|
const detectedType = await FileType.fromBuffer(headerBuffer);
|
||||||
|
|
||||||
|
if (!detectedType) {
|
||||||
|
logger.warn('Unable to detect file type from content', {
|
||||||
|
operation: 'vehicles.uploadImage.type_detection_failed',
|
||||||
|
userId,
|
||||||
|
vehicleId,
|
||||||
|
contentType,
|
||||||
|
fileName: mp.filename,
|
||||||
|
});
|
||||||
|
return reply.code(415).send({
|
||||||
|
error: 'Unsupported Media Type',
|
||||||
|
message: 'Unable to verify file type from content'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify detected type matches claimed Content-Type
|
||||||
|
const allowedDetectedTypes = allowedTypes.get(contentType);
|
||||||
|
if (!allowedDetectedTypes || !allowedDetectedTypes.has(detectedType.mime)) {
|
||||||
|
logger.warn('File content does not match Content-Type header', {
|
||||||
|
operation: 'vehicles.uploadImage.type_mismatch',
|
||||||
|
userId,
|
||||||
|
vehicleId,
|
||||||
|
claimedType: contentType,
|
||||||
|
detectedType: detectedType.mime,
|
||||||
|
fileName: mp.filename,
|
||||||
|
});
|
||||||
|
return reply.code(415).send({
|
||||||
|
error: 'Unsupported Media Type',
|
||||||
|
message: `File content (${detectedType.mime}) does not match claimed type (${contentType})`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete existing image if present
|
||||||
|
if (vehicle.imageStorageKey && vehicle.imageStorageBucket) {
|
||||||
|
const storage = getStorageService();
|
||||||
|
try {
|
||||||
|
await storage.deleteObject(vehicle.imageStorageBucket, vehicle.imageStorageKey);
|
||||||
|
logger.info('Deleted existing vehicle image', {
|
||||||
|
operation: 'vehicles.uploadImage.delete_existing',
|
||||||
|
userId,
|
||||||
|
vehicleId,
|
||||||
|
storageKey: vehicle.imageStorageKey,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Failed to delete existing vehicle image', {
|
||||||
|
operation: 'vehicles.uploadImage.delete_existing_failed',
|
||||||
|
userId,
|
||||||
|
vehicleId,
|
||||||
|
storageKey: vehicle.imageStorageKey,
|
||||||
|
error: e instanceof Error ? e.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalName: string = mp.filename || 'vehicle-image';
|
||||||
|
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 bucket = 'vehicle-images';
|
||||||
|
const unique = crypto.randomBytes(32).toString('hex');
|
||||||
|
const key = `vehicle-images/${userId}/${vehicleId}/${unique}.${ext}`;
|
||||||
|
|
||||||
|
await storage.putObject(bucket, key, counter, contentType, { 'x-original-filename': originalName });
|
||||||
|
|
||||||
|
const updated = await this.vehiclesService.updateVehicleImage(vehicleId, userId, {
|
||||||
|
imageStorageBucket: bucket,
|
||||||
|
imageStorageKey: key,
|
||||||
|
imageFileName: originalName,
|
||||||
|
imageContentType: contentType,
|
||||||
|
imageFileSize: counter.bytes,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Vehicle image upload completed', {
|
||||||
|
operation: 'vehicles.uploadImage.success',
|
||||||
|
userId,
|
||||||
|
vehicleId,
|
||||||
|
fileName: originalName,
|
||||||
|
contentType,
|
||||||
|
detectedType: detectedType.mime,
|
||||||
|
fileSize: counter.bytes,
|
||||||
|
storageKey: key,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(200).send(updated);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error uploading vehicle image', { error, vehicleId, userId });
|
||||||
|
return reply.code(500).send({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: 'Failed to upload image'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadImage(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
||||||
|
const userId = (request as any).user.sub;
|
||||||
|
const vehicleId = request.params.id;
|
||||||
|
|
||||||
|
logger.info('Vehicle image download requested', {
|
||||||
|
operation: 'vehicles.downloadImage',
|
||||||
|
userId,
|
||||||
|
vehicleId,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const vehicle = await this.vehiclesService.getVehicleRaw(vehicleId, userId);
|
||||||
|
if (!vehicle || !vehicle.imageStorageBucket || !vehicle.imageStorageKey) {
|
||||||
|
logger.warn('Vehicle or image not found for download', {
|
||||||
|
operation: 'vehicles.downloadImage.not_found',
|
||||||
|
userId,
|
||||||
|
vehicleId,
|
||||||
|
hasVehicle: !!vehicle,
|
||||||
|
hasStorageInfo: !!(vehicle?.imageStorageBucket && vehicle?.imageStorageKey),
|
||||||
|
});
|
||||||
|
return reply.code(404).send({ error: 'Not Found', message: 'Vehicle image not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = getStorageService();
|
||||||
|
const contentType = vehicle.imageContentType || 'application/octet-stream';
|
||||||
|
const filename = vehicle.imageFileName || path.basename(vehicle.imageStorageKey);
|
||||||
|
|
||||||
|
reply.header('Content-Type', contentType);
|
||||||
|
reply.header('Content-Disposition', `inline; filename="${encodeURIComponent(filename)}"`);
|
||||||
|
|
||||||
|
logger.info('Vehicle image download initiated', {
|
||||||
|
operation: 'vehicles.downloadImage.success',
|
||||||
|
userId,
|
||||||
|
vehicleId,
|
||||||
|
fileName: filename,
|
||||||
|
contentType,
|
||||||
|
fileSize: vehicle.imageFileSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = await storage.getObjectStream(vehicle.imageStorageBucket, vehicle.imageStorageKey);
|
||||||
|
return reply.send(stream);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error downloading vehicle image', { error, vehicleId, userId });
|
||||||
|
return reply.code(500).send({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: 'Failed to download image'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteImage(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
||||||
|
const userId = (request as any).user.sub;
|
||||||
|
const vehicleId = request.params.id;
|
||||||
|
|
||||||
|
logger.info('Vehicle image delete requested', {
|
||||||
|
operation: 'vehicles.deleteImage',
|
||||||
|
userId,
|
||||||
|
vehicleId,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const vehicle = await this.vehiclesService.getVehicleRaw(vehicleId, userId);
|
||||||
|
if (!vehicle) {
|
||||||
|
logger.warn('Vehicle not found for image delete', {
|
||||||
|
operation: 'vehicles.deleteImage.not_found',
|
||||||
|
userId,
|
||||||
|
vehicleId,
|
||||||
|
});
|
||||||
|
return reply.code(404).send({ error: 'Not Found', message: 'Vehicle not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete file from storage if exists
|
||||||
|
if (vehicle.imageStorageKey && vehicle.imageStorageBucket) {
|
||||||
|
const storage = getStorageService();
|
||||||
|
try {
|
||||||
|
await storage.deleteObject(vehicle.imageStorageBucket, vehicle.imageStorageKey);
|
||||||
|
logger.info('Vehicle image file deleted from storage', {
|
||||||
|
operation: 'vehicles.deleteImage.storage_cleanup',
|
||||||
|
userId,
|
||||||
|
vehicleId,
|
||||||
|
storageKey: vehicle.imageStorageKey,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Failed to delete vehicle image file from storage', {
|
||||||
|
operation: 'vehicles.deleteImage.storage_cleanup_failed',
|
||||||
|
userId,
|
||||||
|
vehicleId,
|
||||||
|
storageKey: vehicle.imageStorageKey,
|
||||||
|
error: e instanceof Error ? e.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear image metadata from database
|
||||||
|
await this.vehiclesService.updateVehicleImage(vehicleId, userId, null);
|
||||||
|
|
||||||
|
logger.info('Vehicle image deleted', {
|
||||||
|
operation: 'vehicles.deleteImage.success',
|
||||||
|
userId,
|
||||||
|
vehicleId,
|
||||||
|
hadFile: !!(vehicle.imageStorageKey),
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting vehicle image', { error, vehicleId, userId });
|
||||||
|
return reply.code(500).send({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: 'Failed to delete image'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,25 @@ export const vehiclesRoutes: FastifyPluginAsync = async (
|
|||||||
handler: vehiclesController.getDropdownOptions.bind(vehiclesController)
|
handler: vehiclesController.getDropdownOptions.bind(vehiclesController)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Vehicle image routes - must be before :id to avoid conflicts
|
||||||
|
// POST /api/vehicles/:id/image - Upload vehicle image
|
||||||
|
fastify.post<{ Params: VehicleParams }>('/vehicles/:id/image', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
handler: vehiclesController.uploadImage.bind(vehiclesController)
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/vehicles/:id/image - Download vehicle image
|
||||||
|
fastify.get<{ Params: VehicleParams }>('/vehicles/:id/image', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
handler: vehiclesController.downloadImage.bind(vehiclesController)
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/vehicles/:id/image - Delete vehicle image
|
||||||
|
fastify.delete<{ Params: VehicleParams }>('/vehicles/:id/image', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
handler: vehiclesController.deleteImage.bind(vehiclesController)
|
||||||
|
});
|
||||||
|
|
||||||
// Dynamic :id routes MUST come last to avoid matching specific paths like "dropdown"
|
// Dynamic :id routes MUST come last to avoid matching specific paths like "dropdown"
|
||||||
// GET /api/vehicles/:id - Get specific vehicle
|
// GET /api/vehicles/:id - Get specific vehicle
|
||||||
fastify.get<{ Params: VehicleParams }>('/vehicles/:id', {
|
fastify.get<{ Params: VehicleParams }>('/vehicles/:id', {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
import { Vehicle, CreateVehicleRequest } from '../domain/vehicles.types';
|
import { Vehicle, CreateVehicleRequest, VehicleImageMeta } from '../domain/vehicles.types';
|
||||||
|
|
||||||
export class VehiclesRepository {
|
export class VehiclesRepository {
|
||||||
constructor(private pool: Pool) {}
|
constructor(private pool: Pool) {}
|
||||||
@@ -161,6 +161,45 @@ export class VehiclesRepository {
|
|||||||
return (result.rowCount ?? 0) > 0;
|
return (result.rowCount ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateImageMeta(id: string, userId: string, meta: VehicleImageMeta | null): Promise<Vehicle | null> {
|
||||||
|
if (meta === null) {
|
||||||
|
const query = `
|
||||||
|
UPDATE vehicles SET
|
||||||
|
image_storage_bucket = NULL,
|
||||||
|
image_storage_key = NULL,
|
||||||
|
image_file_name = NULL,
|
||||||
|
image_content_type = NULL,
|
||||||
|
image_file_size = NULL,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1 AND user_id = $2
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
const result = await this.pool.query(query, [id, userId]);
|
||||||
|
return result.rows[0] ? this.mapRow(result.rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
UPDATE vehicles SET
|
||||||
|
image_storage_bucket = $1,
|
||||||
|
image_storage_key = $2,
|
||||||
|
image_file_name = $3,
|
||||||
|
image_content_type = $4,
|
||||||
|
image_file_size = $5,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $6 AND user_id = $7
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
const result = await this.pool.query(query, [
|
||||||
|
meta.imageStorageBucket,
|
||||||
|
meta.imageStorageKey,
|
||||||
|
meta.imageFileName,
|
||||||
|
meta.imageContentType,
|
||||||
|
meta.imageFileSize,
|
||||||
|
id,
|
||||||
|
userId
|
||||||
|
]);
|
||||||
|
return result.rows[0] ? this.mapRow(result.rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
private mapRow(row: any): Vehicle {
|
private mapRow(row: any): Vehicle {
|
||||||
return {
|
return {
|
||||||
@@ -183,6 +222,11 @@ export class VehiclesRepository {
|
|||||||
deletedAt: row.deleted_at,
|
deletedAt: row.deleted_at,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
|
imageStorageBucket: row.image_storage_bucket,
|
||||||
|
imageStorageKey: row.image_storage_key,
|
||||||
|
imageFileName: row.image_file_name,
|
||||||
|
imageContentType: row.image_content_type,
|
||||||
|
imageFileSize: row.image_file_size,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
Vehicle,
|
Vehicle,
|
||||||
CreateVehicleRequest,
|
CreateVehicleRequest,
|
||||||
UpdateVehicleRequest,
|
UpdateVehicleRequest,
|
||||||
VehicleResponse
|
VehicleResponse,
|
||||||
|
VehicleImageMeta
|
||||||
} from './vehicles.types';
|
} from './vehicles.types';
|
||||||
import { logger } from '../../../core/logging/logger';
|
import { logger } from '../../../core/logging/logger';
|
||||||
import { cacheService } from '../../../core/config/redis';
|
import { cacheService } from '../../../core/config/redis';
|
||||||
@@ -139,6 +140,23 @@ export class VehiclesService {
|
|||||||
await this.invalidateUserCache(userId);
|
await this.invalidateUserCache(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getVehicleRaw(id: string, userId: string): Promise<Vehicle | null> {
|
||||||
|
const vehicle = await this.repository.findById(id);
|
||||||
|
if (!vehicle || vehicle.userId !== userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return vehicle;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateVehicleImage(id: string, userId: string, meta: VehicleImageMeta | null): Promise<VehicleResponse | null> {
|
||||||
|
const updated = await this.repository.updateImageMeta(id, userId, meta);
|
||||||
|
if (!updated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await this.invalidateUserCache(userId);
|
||||||
|
return this.toResponse(updated);
|
||||||
|
}
|
||||||
|
|
||||||
private async invalidateUserCache(userId: string): Promise<void> {
|
private async invalidateUserCache(userId: string): Promise<void> {
|
||||||
const cacheKey = `${this.cachePrefix}:user:${userId}`;
|
const cacheKey = `${this.cachePrefix}:user:${userId}`;
|
||||||
await cacheService.del(cacheKey);
|
await cacheService.del(cacheKey);
|
||||||
@@ -227,6 +245,7 @@ export class VehiclesService {
|
|||||||
isActive: vehicle.isActive,
|
isActive: vehicle.isActive,
|
||||||
createdAt: vehicle.createdAt.toISOString(),
|
createdAt: vehicle.createdAt.toISOString(),
|
||||||
updatedAt: vehicle.updatedAt.toISOString(),
|
updatedAt: vehicle.updatedAt.toISOString(),
|
||||||
|
imageUrl: vehicle.imageStorageKey ? `/api/vehicles/${vehicle.id}/image` : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export interface Vehicle {
|
|||||||
deletedAt?: Date;
|
deletedAt?: Date;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
imageStorageBucket?: string;
|
||||||
|
imageStorageKey?: string;
|
||||||
|
imageFileName?: string;
|
||||||
|
imageContentType?: string;
|
||||||
|
imageFileSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateVehicleRequest {
|
export interface CreateVehicleRequest {
|
||||||
@@ -73,6 +78,15 @@ export interface VehicleResponse {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VehicleImageMeta {
|
||||||
|
imageStorageBucket: string;
|
||||||
|
imageStorageKey: string;
|
||||||
|
imageFileName: string;
|
||||||
|
imageContentType: string;
|
||||||
|
imageFileSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VINDecodeResult {
|
export interface VINDecodeResult {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Add image metadata columns to vehicles table
|
||||||
|
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS image_storage_bucket VARCHAR(50);
|
||||||
|
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS image_storage_key VARCHAR(500);
|
||||||
|
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS image_file_name VARCHAR(255);
|
||||||
|
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS image_content_type VARCHAR(100);
|
||||||
|
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS image_file_size INTEGER;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN vehicles.image_storage_bucket IS 'Storage bucket for vehicle image';
|
||||||
|
COMMENT ON COLUMN vehicles.image_storage_key IS 'Storage key path for vehicle image file';
|
||||||
|
COMMENT ON COLUMN vehicles.image_file_name IS 'Original filename of uploaded image';
|
||||||
|
COMMENT ON COLUMN vehicles.image_content_type IS 'MIME type of vehicle image';
|
||||||
|
COMMENT ON COLUMN vehicles.image_file_size IS 'Size of vehicle image in bytes';
|
||||||
BIN
data/vehicle-etl/snapshots/2025-12-16/snapshot.sqlite-shm
Normal file
BIN
data/vehicle-etl/snapshots/2025-12-16/snapshot.sqlite-shm
Normal file
Binary file not shown.
BIN
data/vehicle-etl/snapshots/2025-12-16/snapshot.sqlite-wal
Normal file
BIN
data/vehicle-etl/snapshots/2025-12-16/snapshot.sqlite-wal
Normal file
Binary file not shown.
@@ -61,5 +61,22 @@ export const vehiclesApi = {
|
|||||||
getTrims: async (year: number, make: string, model: string): Promise<string[]> => {
|
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)}`);
|
const response = await apiClient.get(`/vehicles/dropdown/trims?year=${year}&make=${encodeURIComponent(make)}&model=${encodeURIComponent(model)}`);
|
||||||
return response.data;
|
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 DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import { Vehicle } from '../types/vehicles.types';
|
import { Vehicle } from '../types/vehicles.types';
|
||||||
import { useUnits } from '../../../core/units/UnitsContext';
|
import { useUnits } from '../../../core/units/UnitsContext';
|
||||||
|
import { VehicleImage } from './VehicleImage';
|
||||||
|
|
||||||
interface VehicleCardProps {
|
interface VehicleCardProps {
|
||||||
vehicle: Vehicle;
|
vehicle: Vehicle;
|
||||||
@@ -16,20 +17,6 @@ interface VehicleCardProps {
|
|||||||
onSelect: (id: string) => void;
|
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> = ({
|
export const VehicleCard: React.FC<VehicleCardProps> = ({
|
||||||
vehicle,
|
vehicle,
|
||||||
onEdit,
|
onEdit,
|
||||||
@@ -57,7 +44,7 @@ export const VehicleCard: React.FC<VehicleCardProps> = ({
|
|||||||
sx={{ flexGrow: 1 }}
|
sx={{ flexGrow: 1 }}
|
||||||
>
|
>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<CarThumb color={vehicle.color || "#F2EAEA"} />
|
<VehicleImage vehicle={vehicle} height={96} />
|
||||||
|
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1 }}>
|
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1 }}>
|
||||||
{displayName}
|
{displayName}
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { Button } from '../../../shared-minimal/components/Button';
|
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 { vehiclesApi } from '../api/vehicles.api';
|
||||||
|
import { VehicleImageUpload } from './VehicleImageUpload';
|
||||||
|
|
||||||
const vehicleSchema = z
|
const vehicleSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -57,8 +58,9 @@ const vehicleSchema = z
|
|||||||
interface VehicleFormProps {
|
interface VehicleFormProps {
|
||||||
onSubmit: (data: CreateVehicleRequest) => void;
|
onSubmit: (data: CreateVehicleRequest) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
initialData?: Partial<CreateVehicleRequest>;
|
initialData?: Partial<CreateVehicleRequest> & { id?: string; imageUrl?: string };
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
onImageUpdate?: (vehicle: Vehicle) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VehicleForm: React.FC<VehicleFormProps> = ({
|
export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||||
@@ -66,6 +68,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|||||||
onCancel,
|
onCancel,
|
||||||
initialData,
|
initialData,
|
||||||
loading,
|
loading,
|
||||||
|
onImageUpdate,
|
||||||
}) => {
|
}) => {
|
||||||
const [years, setYears] = useState<number[]>([]);
|
const [years, setYears] = useState<number[]>([]);
|
||||||
const [makes, setMakes] = useState<string[]>([]);
|
const [makes, setMakes] = useState<string[]>([]);
|
||||||
@@ -80,6 +83,10 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|||||||
const [loadingDropdowns, setLoadingDropdowns] = useState(false);
|
const [loadingDropdowns, setLoadingDropdowns] = useState(false);
|
||||||
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 isEditMode = !!initialData?.id;
|
||||||
|
const vehicleId = initialData?.id;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -332,8 +339,53 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|||||||
}
|
}
|
||||||
}, [watchedYear, selectedMake, selectedModel, watch('trimLevel')]);
|
}, [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 (
|
return (
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
<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>
|
<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">
|
||||||
VIN or License Plate <span className="text-red-500">*</span>
|
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 { FuelLogResponse, UpdateFuelLogRequest } from '../../fuel-logs/types/fuel-logs.types';
|
||||||
import { FuelLogEditDialog } from '../../fuel-logs/components/FuelLogEditDialog';
|
import { FuelLogEditDialog } from '../../fuel-logs/components/FuelLogEditDialog';
|
||||||
import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api';
|
import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api';
|
||||||
|
import { VehicleImage } from '../components/VehicleImage';
|
||||||
// Theme colors now defined in Tailwind config
|
|
||||||
|
|
||||||
interface VehicleDetailMobileProps {
|
interface VehicleDetailMobileProps {
|
||||||
vehicle: Vehicle;
|
vehicle: Vehicle;
|
||||||
@@ -31,19 +30,6 @@ const Section: React.FC<{ title: string; children: React.ReactNode }> = ({
|
|||||||
</Box>
|
</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> = ({
|
export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
|
||||||
vehicle,
|
vehicle,
|
||||||
onBack,
|
onBack,
|
||||||
@@ -167,7 +153,7 @@ export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
|
|||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 3 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 3 }}>
|
||||||
<Box sx={{ width: 112 }}>
|
<Box sx={{ width: 112 }}>
|
||||||
<CarThumb color={vehicle.color || "#F2EAEA"} />
|
<VehicleImage vehicle={vehicle} height={96} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 600 }}>
|
<Typography variant="h5" sx={{ fontWeight: 600 }}>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Card, CardActionArea, Box, Typography } from '@mui/material';
|
import { Card, CardActionArea, Box, Typography } from '@mui/material';
|
||||||
import { Vehicle } from '../types/vehicles.types';
|
import { Vehicle } from '../types/vehicles.types';
|
||||||
|
import { VehicleImage } from '../components/VehicleImage';
|
||||||
|
|
||||||
interface VehicleMobileCardProps {
|
interface VehicleMobileCardProps {
|
||||||
vehicle: Vehicle;
|
vehicle: Vehicle;
|
||||||
@@ -12,20 +13,6 @@ interface VehicleMobileCardProps {
|
|||||||
compact?: boolean;
|
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> = ({
|
export const VehicleMobileCard: React.FC<VehicleMobileCardProps> = ({
|
||||||
vehicle,
|
vehicle,
|
||||||
onClick,
|
onClick,
|
||||||
@@ -46,7 +33,7 @@ export const VehicleMobileCard: React.FC<VehicleMobileCardProps> = ({
|
|||||||
>
|
>
|
||||||
<CardActionArea onClick={() => onClick?.(vehicle)}>
|
<CardActionArea onClick={() => onClick?.(vehicle)}>
|
||||||
<Box sx={{ p: 2 }}>
|
<Box sx={{ p: 2 }}>
|
||||||
<CarThumb color={vehicle.color || "#F2EAEA"} />
|
<VehicleImage vehicle={vehicle} height={120} />
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 0.5 }}>
|
<Typography variant="h6" sx={{ fontWeight: 700, mb: 0.5 }}>
|
||||||
{displayName}
|
{displayName}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Vehicle } from '../types/vehicles.types';
|
|||||||
import { vehiclesApi } from '../api/vehicles.api';
|
import { vehiclesApi } from '../api/vehicles.api';
|
||||||
import { Card } from '../../../shared-minimal/components/Card';
|
import { Card } from '../../../shared-minimal/components/Card';
|
||||||
import { VehicleForm } from '../components/VehicleForm';
|
import { VehicleForm } from '../components/VehicleForm';
|
||||||
|
import { VehicleImage } from '../components/VehicleImage';
|
||||||
import { useFuelLogs } from '../../fuel-logs/hooks/useFuelLogs';
|
import { useFuelLogs } from '../../fuel-logs/hooks/useFuelLogs';
|
||||||
import { FuelLogResponse, UpdateFuelLogRequest } from '../../fuel-logs/types/fuel-logs.types';
|
import { FuelLogResponse, UpdateFuelLogRequest } from '../../fuel-logs/types/fuel-logs.types';
|
||||||
import { FuelLogEditDialog } from '../../fuel-logs/components/FuelLogEditDialog';
|
import { FuelLogEditDialog } from '../../fuel-logs/components/FuelLogEditDialog';
|
||||||
@@ -231,6 +232,7 @@ export const VehicleDetailPage: React.FC = () => {
|
|||||||
initialData={vehicle}
|
initialData={vehicle}
|
||||||
onSubmit={handleUpdateVehicle}
|
onSubmit={handleUpdateVehicle}
|
||||||
onCancel={handleCancelEdit}
|
onCancel={handleCancelEdit}
|
||||||
|
onImageUpdate={(updated) => setVehicle(updated)}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -287,9 +289,25 @@ export const VehicleDetailPage: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
|
<Box sx={{ display: 'flex', gap: 3, mb: 3 }}>
|
||||||
Vehicle Details
|
<Box sx={{ width: 200, flexShrink: 0 }}>
|
||||||
</Typography>
|
<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">
|
<form className="space-y-4">
|
||||||
<DetailField
|
<DetailField
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface Vehicle {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
imageUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateVehicleRequest {
|
export interface CreateVehicleRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user