MVP Build
This commit is contained in:
32
frontend/src/features/vehicles/api/vehicles.api.ts
Normal file
32
frontend/src/features/vehicles/api/vehicles.api.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @ai-summary API calls for vehicles feature
|
||||
*/
|
||||
|
||||
import { apiClient } from '../../../core/api/client';
|
||||
import { Vehicle, CreateVehicleRequest, UpdateVehicleRequest } from '../types/vehicles.types';
|
||||
|
||||
export const vehiclesApi = {
|
||||
getAll: async (): Promise<Vehicle[]> => {
|
||||
const response = await apiClient.get('/vehicles');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Vehicle> => {
|
||||
const response = await apiClient.get(`/vehicles/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: CreateVehicleRequest): Promise<Vehicle> => {
|
||||
const response = await apiClient.post('/vehicles', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateVehicleRequest): Promise<Vehicle> => {
|
||||
const response = await apiClient.put(`/vehicles/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/vehicles/${id}`);
|
||||
},
|
||||
};
|
||||
64
frontend/src/features/vehicles/components/VehicleCard.tsx
Normal file
64
frontend/src/features/vehicles/components/VehicleCard.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @ai-summary Vehicle card component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Vehicle } from '../types/vehicles.types';
|
||||
import { Card } from '../../../shared-minimal/components/Card';
|
||||
import { Button } from '../../../shared-minimal/components/Button';
|
||||
|
||||
interface VehicleCardProps {
|
||||
vehicle: Vehicle;
|
||||
onEdit: (vehicle: Vehicle) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
export const VehicleCard: React.FC<VehicleCardProps> = ({
|
||||
vehicle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onSelect,
|
||||
}) => {
|
||||
return (
|
||||
<Card className="hover:shadow-md transition-shadow cursor-pointer" onClick={() => onSelect(vehicle.id)}>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{vehicle.nickname || `${vehicle.year} ${vehicle.make} ${vehicle.model}`}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">VIN: {vehicle.vin}</p>
|
||||
{vehicle.licensePlate && (
|
||||
<p className="text-sm text-gray-500">License: {vehicle.licensePlate}</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
Odometer: {vehicle.odometerReading.toLocaleString()} miles
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(vehicle);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(vehicle.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
115
frontend/src/features/vehicles/components/VehicleForm.tsx
Normal file
115
frontend/src/features/vehicles/components/VehicleForm.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @ai-summary Vehicle form component for create/edit
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '../../../shared-minimal/components/Button';
|
||||
import { CreateVehicleRequest } from '../types/vehicles.types';
|
||||
|
||||
const vehicleSchema = z.object({
|
||||
vin: z.string().length(17, 'VIN must be exactly 17 characters'),
|
||||
nickname: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
licensePlate: z.string().optional(),
|
||||
odometerReading: z.number().min(0).optional(),
|
||||
});
|
||||
|
||||
interface VehicleFormProps {
|
||||
onSubmit: (data: CreateVehicleRequest) => void;
|
||||
onCancel: () => void;
|
||||
initialData?: Partial<CreateVehicleRequest>;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
initialData,
|
||||
loading,
|
||||
}) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<CreateVehicleRequest>({
|
||||
resolver: zodResolver(vehicleSchema),
|
||||
defaultValues: initialData,
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
VIN <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<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"
|
||||
placeholder="Enter 17-character VIN"
|
||||
/>
|
||||
{errors.vin && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.vin.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nickname
|
||||
</label>
|
||||
<input
|
||||
{...register('nickname')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="e.g., Family Car"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Color
|
||||
</label>
|
||||
<input
|
||||
{...register('color')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="e.g., Blue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
License Plate
|
||||
</label>
|
||||
<input
|
||||
{...register('licensePlate')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="e.g., ABC-123"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Current Odometer Reading
|
||||
</label>
|
||||
<input
|
||||
{...register('odometerReading', { valueAsNumber: true })}
|
||||
type="number"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="e.g., 50000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end pt-4">
|
||||
<Button variant="secondary" onClick={onCancel} type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={loading}>
|
||||
{initialData ? 'Update Vehicle' : 'Add Vehicle'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
78
frontend/src/features/vehicles/hooks/useVehicles.ts
Normal file
78
frontend/src/features/vehicles/hooks/useVehicles.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @ai-summary React hooks for vehicles feature
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { vehiclesApi } from '../api/vehicles.api';
|
||||
import { CreateVehicleRequest, UpdateVehicleRequest } from '../types/vehicles.types';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface ApiError {
|
||||
response?: {
|
||||
data?: {
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const useVehicles = () => {
|
||||
return useQuery({
|
||||
queryKey: ['vehicles'],
|
||||
queryFn: vehiclesApi.getAll,
|
||||
});
|
||||
};
|
||||
|
||||
export const useVehicle = (id: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['vehicles', id],
|
||||
queryFn: () => vehiclesApi.getById(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateVehicle = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateVehicleRequest) => vehiclesApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vehicles'] });
|
||||
toast.success('Vehicle added successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to add vehicle');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateVehicle = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateVehicleRequest }) =>
|
||||
vehiclesApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vehicles'] });
|
||||
toast.success('Vehicle updated successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to update vehicle');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteVehicle = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => vehiclesApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vehicles'] });
|
||||
toast.success('Vehicle deleted successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to delete vehicle');
|
||||
},
|
||||
});
|
||||
};
|
||||
89
frontend/src/features/vehicles/pages/VehiclesPage.tsx
Normal file
89
frontend/src/features/vehicles/pages/VehiclesPage.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @ai-summary Main vehicles page
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useVehicles, useCreateVehicle, useDeleteVehicle } from '../hooks/useVehicles';
|
||||
import { VehicleCard } from '../components/VehicleCard';
|
||||
import { VehicleForm } from '../components/VehicleForm';
|
||||
import { Button } from '../../../shared-minimal/components/Button';
|
||||
import { Card } from '../../../shared-minimal/components/Card';
|
||||
import { useAppStore } from '../../../core/store';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export const VehiclesPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { data: vehicles, isLoading } = useVehicles();
|
||||
const createVehicle = useCreateVehicle();
|
||||
const deleteVehicle = useDeleteVehicle();
|
||||
const setSelectedVehicle = useAppStore(state => state.setSelectedVehicle);
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
const handleSelectVehicle = (id: string) => {
|
||||
setSelectedVehicle(id);
|
||||
navigate(`/vehicles/${id}`);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm('Are you sure you want to delete this vehicle?')) {
|
||||
await deleteVehicle.mutateAsync(id);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading vehicles...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">My Vehicles</h1>
|
||||
{!showForm && (
|
||||
<Button onClick={() => setShowForm(true)}>Add Vehicle</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold mb-4">Add New Vehicle</h2>
|
||||
<VehicleForm
|
||||
onSubmit={async (data) => {
|
||||
await createVehicle.mutateAsync(data);
|
||||
setShowForm(false);
|
||||
}}
|
||||
onCancel={() => setShowForm(false)}
|
||||
loading={createVehicle.isPending}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{vehicles?.length === 0 ? (
|
||||
<Card>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 mb-4">No vehicles added yet</p>
|
||||
{!showForm && (
|
||||
<Button onClick={() => setShowForm(true)}>Add Your First Vehicle</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{vehicles?.map((vehicle) => (
|
||||
<VehicleCard
|
||||
key={vehicle.id}
|
||||
vehicle={vehicle}
|
||||
onEdit={(v) => console.log('Edit', v)}
|
||||
onDelete={handleDelete}
|
||||
onSelect={handleSelectVehicle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
34
frontend/src/features/vehicles/types/vehicles.types.ts
Normal file
34
frontend/src/features/vehicles/types/vehicles.types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* @ai-summary Type definitions for vehicles feature
|
||||
*/
|
||||
|
||||
export interface Vehicle {
|
||||
id: string;
|
||||
userId: string;
|
||||
vin: string;
|
||||
make?: string;
|
||||
model?: string;
|
||||
year?: number;
|
||||
nickname?: string;
|
||||
color?: string;
|
||||
licensePlate?: string;
|
||||
odometerReading: number;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateVehicleRequest {
|
||||
vin: string;
|
||||
nickname?: string;
|
||||
color?: string;
|
||||
licensePlate?: string;
|
||||
odometerReading?: number;
|
||||
}
|
||||
|
||||
export interface UpdateVehicleRequest {
|
||||
nickname?: string;
|
||||
color?: string;
|
||||
licensePlate?: string;
|
||||
odometerReading?: number;
|
||||
}
|
||||
Reference in New Issue
Block a user