MVP Build

This commit is contained in:
Eric Gullickson
2025-08-09 12:47:15 -05:00
parent 2e8816df7f
commit 8f5117a4e2
92 changed files with 5910 additions and 0 deletions

View 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}`);
},
};

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

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

View 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');
},
});
};

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

View 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;
}