565 lines
23 KiB
TypeScript
565 lines
23 KiB
TypeScript
import { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useForm } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { z } from 'zod';
|
|
import {
|
|
Truck,
|
|
Plus,
|
|
Search,
|
|
Filter,
|
|
Edit2,
|
|
Trash2,
|
|
X,
|
|
Loader2,
|
|
AlertCircle,
|
|
Gauge,
|
|
} from 'lucide-react';
|
|
import { vehiclesApi } from '../services/api/vehicles';
|
|
import { customersApi } from '../services/api/customers';
|
|
import type { Vehicle, VehicleType, VehicleStatus, VehicleFilters } from '../types';
|
|
import type { Customer } from '../services/api/customers';
|
|
|
|
const VEHICLE_TYPES: { value: VehicleType; label: string }[] = [
|
|
{ value: 'truck', label: 'Camion' },
|
|
{ value: 'trailer', label: 'Trailer' },
|
|
{ value: 'bus', label: 'Autobus' },
|
|
{ value: 'pickup', label: 'Pickup' },
|
|
{ value: 'other', label: 'Otro' },
|
|
];
|
|
|
|
const VEHICLE_STATUS: { value: VehicleStatus; label: string; color: string }[] = [
|
|
{ value: 'active', label: 'Activo', color: 'bg-green-100 text-green-800' },
|
|
{ value: 'inactive', label: 'Inactivo', color: 'bg-gray-100 text-gray-800' },
|
|
{ value: 'sold', label: 'Vendido', color: 'bg-red-100 text-red-800' },
|
|
];
|
|
|
|
const vehicleSchema = z.object({
|
|
customerId: z.string().min(1, 'Cliente requerido'),
|
|
licensePlate: z.string().min(1, 'Placas requeridas'),
|
|
vin: z.string().optional(),
|
|
economicNumber: z.string().optional(),
|
|
make: z.string().min(1, 'Marca requerida'),
|
|
model: z.string().min(1, 'Modelo requerido'),
|
|
year: z.number().min(1900).max(new Date().getFullYear() + 1),
|
|
color: z.string().optional(),
|
|
vehicleType: z.enum(['truck', 'trailer', 'bus', 'pickup', 'other']),
|
|
currentOdometer: z.number().optional(),
|
|
notes: z.string().optional(),
|
|
});
|
|
|
|
type VehicleForm = z.infer<typeof vehicleSchema>;
|
|
|
|
export function VehiclesPage() {
|
|
const queryClient = useQueryClient();
|
|
const [filters, setFilters] = useState<VehicleFilters>({ page: 1, pageSize: 20 });
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [editingVehicle, setEditingVehicle] = useState<Vehicle | null>(null);
|
|
const [customerSearch, setCustomerSearch] = useState('');
|
|
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
|
|
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
reset,
|
|
setValue,
|
|
formState: { errors },
|
|
} = useForm<VehicleForm>({
|
|
resolver: zodResolver(vehicleSchema),
|
|
defaultValues: {
|
|
vehicleType: 'truck',
|
|
year: new Date().getFullYear(),
|
|
},
|
|
});
|
|
|
|
// Fetch vehicles
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: ['vehicles', filters],
|
|
queryFn: () => vehiclesApi.list(filters),
|
|
});
|
|
|
|
// Fetch customers for search
|
|
const { data: customersData } = useQuery({
|
|
queryKey: ['customers', customerSearch],
|
|
queryFn: () => customersApi.list({ search: customerSearch, pageSize: 10 }),
|
|
enabled: customerSearch.length >= 2,
|
|
});
|
|
|
|
// Create mutation
|
|
const createMutation = useMutation({
|
|
mutationFn: (data: VehicleForm) => vehiclesApi.create(data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['vehicles'] });
|
|
handleCloseModal();
|
|
},
|
|
});
|
|
|
|
// Update mutation
|
|
const updateMutation = useMutation({
|
|
mutationFn: ({ id, data }: { id: string; data: Partial<VehicleForm> }) =>
|
|
vehiclesApi.update(id, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['vehicles'] });
|
|
handleCloseModal();
|
|
},
|
|
});
|
|
|
|
// Delete mutation
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (id: string) => vehiclesApi.delete(id),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['vehicles'] });
|
|
},
|
|
});
|
|
|
|
const vehicles = data?.data?.data || [];
|
|
const customers = customersData?.data?.data || [];
|
|
|
|
const handleSearch = () => {
|
|
setFilters({ ...filters, search: searchTerm, page: 1 });
|
|
};
|
|
|
|
const handleOpenCreate = () => {
|
|
setEditingVehicle(null);
|
|
setSelectedCustomer(null);
|
|
setCustomerSearch('');
|
|
reset({
|
|
vehicleType: 'truck',
|
|
year: new Date().getFullYear(),
|
|
});
|
|
setShowModal(true);
|
|
};
|
|
|
|
const handleOpenEdit = (vehicle: Vehicle) => {
|
|
setEditingVehicle(vehicle);
|
|
reset({
|
|
customerId: vehicle.customerId,
|
|
licensePlate: vehicle.licensePlate,
|
|
vin: vehicle.vin,
|
|
economicNumber: vehicle.economicNumber,
|
|
make: vehicle.make,
|
|
model: vehicle.model,
|
|
year: vehicle.year,
|
|
color: vehicle.color,
|
|
vehicleType: vehicle.vehicleType,
|
|
currentOdometer: vehicle.currentOdometer,
|
|
notes: vehicle.notes,
|
|
});
|
|
setShowModal(true);
|
|
};
|
|
|
|
const handleCloseModal = () => {
|
|
setShowModal(false);
|
|
setEditingVehicle(null);
|
|
setSelectedCustomer(null);
|
|
reset();
|
|
};
|
|
|
|
const handleCustomerSelect = (customer: Customer) => {
|
|
setSelectedCustomer(customer);
|
|
setValue('customerId', customer.id);
|
|
setShowCustomerDropdown(false);
|
|
setCustomerSearch(customer.name);
|
|
};
|
|
|
|
const onSubmit = (data: VehicleForm) => {
|
|
if (editingVehicle) {
|
|
updateMutation.mutate({ id: editingVehicle.id, data });
|
|
} else {
|
|
createMutation.mutate(data);
|
|
}
|
|
};
|
|
|
|
const handleDelete = (vehicle: Vehicle) => {
|
|
if (confirm(`Eliminar vehiculo ${vehicle.make} ${vehicle.model}?`)) {
|
|
deleteMutation.mutate(vehicle.id);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">Vehiculos</h1>
|
|
<p className="text-sm text-gray-500">Gestiona los vehiculos registrados</p>
|
|
</div>
|
|
<button
|
|
onClick={handleOpenCreate}
|
|
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Nuevo Vehiculo
|
|
</button>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
<div className="flex flex-1 items-center gap-2">
|
|
<div className="relative flex-1 max-w-md">
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
placeholder="Buscar por placa, marca, modelo..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
|
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={handleSearch}
|
|
className="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200"
|
|
>
|
|
Buscar
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Filter className="h-4 w-4 text-gray-400" />
|
|
<select
|
|
value={filters.vehicleType || ''}
|
|
onChange={(e) => setFilters({ ...filters, vehicleType: e.target.value as VehicleType || undefined, page: 1 })}
|
|
className="rounded-lg border border-gray-300 py-2 pl-3 pr-8 text-sm focus:border-diesel-500 focus:outline-none"
|
|
>
|
|
<option value="">Todos los tipos</option>
|
|
{VEHICLE_TYPES.map((type) => (
|
|
<option key={type.value} value={type.value}>{type.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
|
{isLoading ? (
|
|
<div className="flex h-64 items-center justify-center">
|
|
<Loader2 className="h-8 w-8 animate-spin text-diesel-600" />
|
|
</div>
|
|
) : error ? (
|
|
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
|
<AlertCircle className="mb-2 h-8 w-8" />
|
|
<p>Error al cargar vehiculos</p>
|
|
</div>
|
|
) : vehicles.length === 0 ? (
|
|
<div className="flex h-64 flex-col items-center justify-center text-gray-500">
|
|
<Truck className="mb-2 h-8 w-8" />
|
|
<p>No hay vehiculos registrados</p>
|
|
<button
|
|
onClick={handleOpenCreate}
|
|
className="mt-2 text-sm text-diesel-600 hover:text-diesel-700"
|
|
>
|
|
Registrar primer vehiculo
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
|
Vehiculo
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
|
Placas
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
|
Tipo
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
|
Odometro
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
|
Estado
|
|
</th>
|
|
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
|
|
Acciones
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200">
|
|
{vehicles.map((vehicle: Vehicle) => {
|
|
const typeLabel = VEHICLE_TYPES.find(t => t.value === vehicle.vehicleType)?.label || vehicle.vehicleType;
|
|
const statusConfig = VEHICLE_STATUS.find(s => s.value === vehicle.status) || VEHICLE_STATUS[0];
|
|
|
|
return (
|
|
<tr key={vehicle.id} className="hover:bg-gray-50">
|
|
<td className="whitespace-nowrap px-6 py-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
|
|
<Truck className="h-5 w-5 text-gray-600" />
|
|
</div>
|
|
<div>
|
|
<p className="font-medium text-gray-900">{vehicle.make} {vehicle.model}</p>
|
|
<p className="text-sm text-gray-500">{vehicle.year}</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="whitespace-nowrap px-6 py-4">
|
|
<p className="font-medium text-gray-900">{vehicle.licensePlate}</p>
|
|
{vehicle.economicNumber && (
|
|
<p className="text-sm text-gray-500">Eco: {vehicle.economicNumber}</p>
|
|
)}
|
|
</td>
|
|
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
|
{typeLabel}
|
|
</td>
|
|
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
|
{vehicle.currentOdometer ? (
|
|
<span className="flex items-center gap-1">
|
|
<Gauge className="h-4 w-4" />
|
|
{vehicle.currentOdometer.toLocaleString()} km
|
|
</span>
|
|
) : '-'}
|
|
</td>
|
|
<td className="whitespace-nowrap px-6 py-4">
|
|
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${statusConfig.color}`}>
|
|
{statusConfig.label}
|
|
</span>
|
|
</td>
|
|
<td className="whitespace-nowrap px-6 py-4 text-right">
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
onClick={() => handleOpenEdit(vehicle)}
|
|
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
|
>
|
|
<Edit2 className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(vehicle)}
|
|
className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
|
|
{/* Pagination */}
|
|
{data?.data && vehicles.length > 0 && (
|
|
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-6 py-3">
|
|
<div className="text-sm text-gray-500">
|
|
Pagina {data.data.page} de {data.data.totalPages}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => setFilters({ ...filters, page: (filters.page || 1) - 1 })}
|
|
disabled={(filters.page || 1) <= 1}
|
|
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
|
>
|
|
Anterior
|
|
</button>
|
|
<button
|
|
onClick={() => setFilters({ ...filters, page: (filters.page || 1) + 1 })}
|
|
disabled={(filters.page || 1) >= data.data.totalPages}
|
|
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
|
>
|
|
Siguiente
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Modal */}
|
|
{showModal && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
<div className="w-full max-w-lg rounded-xl bg-white p-6 shadow-xl max-h-[90vh] overflow-y-auto">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold text-gray-900">
|
|
{editingVehicle ? 'Editar Vehiculo' : 'Nuevo Vehiculo'}
|
|
</h2>
|
|
<button onClick={handleCloseModal} className="text-gray-400 hover:text-gray-600">
|
|
<X className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
|
{/* Customer Search */}
|
|
{!editingVehicle && (
|
|
<div className="relative">
|
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
|
Cliente *
|
|
</label>
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
placeholder="Buscar cliente..."
|
|
value={customerSearch}
|
|
onChange={(e) => {
|
|
setCustomerSearch(e.target.value);
|
|
setShowCustomerDropdown(true);
|
|
}}
|
|
onFocus={() => setShowCustomerDropdown(true)}
|
|
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-diesel-500 focus:outline-none"
|
|
/>
|
|
</div>
|
|
|
|
{showCustomerDropdown && customers.length > 0 && (
|
|
<div className="absolute z-10 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg max-h-48 overflow-y-auto">
|
|
{customers.map((customer: Customer) => (
|
|
<button
|
|
key={customer.id}
|
|
type="button"
|
|
onClick={() => handleCustomerSelect(customer)}
|
|
className="flex w-full items-center gap-2 px-4 py-2 text-left hover:bg-gray-50"
|
|
>
|
|
<span className="font-medium">{customer.name}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{selectedCustomer && (
|
|
<div className="mt-2 rounded-lg bg-diesel-50 px-3 py-2 text-sm text-diesel-700">
|
|
Cliente: {selectedCustomer.name}
|
|
</div>
|
|
)}
|
|
|
|
{errors.customerId && (
|
|
<p className="mt-1 text-sm text-red-600">{errors.customerId.message}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Marca *</label>
|
|
<input
|
|
{...register('make')}
|
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
|
placeholder="Kenworth"
|
|
/>
|
|
{errors.make && <p className="mt-1 text-sm text-red-600">{errors.make.message}</p>}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Modelo *</label>
|
|
<input
|
|
{...register('model')}
|
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
|
placeholder="T800"
|
|
/>
|
|
{errors.model && <p className="mt-1 text-sm text-red-600">{errors.model.message}</p>}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Ano *</label>
|
|
<input
|
|
type="number"
|
|
{...register('year', { valueAsNumber: true })}
|
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
|
/>
|
|
{errors.year && <p className="mt-1 text-sm text-red-600">{errors.year.message}</p>}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Tipo</label>
|
|
<select
|
|
{...register('vehicleType')}
|
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
|
>
|
|
{VEHICLE_TYPES.map((type) => (
|
|
<option key={type.value} value={type.value}>{type.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Placas *</label>
|
|
<input
|
|
{...register('licensePlate')}
|
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
|
placeholder="ABC-123-XY"
|
|
/>
|
|
{errors.licensePlate && <p className="mt-1 text-sm text-red-600">{errors.licensePlate.message}</p>}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">No. Economico</label>
|
|
<input
|
|
{...register('economicNumber')}
|
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
|
placeholder="ECO-001"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">VIN</label>
|
|
<input
|
|
{...register('vin')}
|
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
|
placeholder="1XKAD49X04J038445"
|
|
maxLength={17}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Color</label>
|
|
<input
|
|
{...register('color')}
|
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
|
placeholder="Blanco"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Odometro (km)</label>
|
|
<input
|
|
type="number"
|
|
{...register('currentOdometer', { valueAsNumber: true })}
|
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
|
placeholder="125000"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">Notas</label>
|
|
<textarea
|
|
{...register('notes')}
|
|
rows={2}
|
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-diesel-500 focus:outline-none"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 pt-4">
|
|
<button
|
|
type="button"
|
|
onClick={handleCloseModal}
|
|
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
|
>
|
|
Cancelar
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={createMutation.isPending || updateMutation.isPending}
|
|
className="flex items-center gap-2 rounded-lg bg-diesel-600 px-4 py-2 text-sm font-medium text-white hover:bg-diesel-700 disabled:opacity-50"
|
|
>
|
|
{(createMutation.isPending || updateMutation.isPending) && (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
)}
|
|
{editingVehicle ? 'Guardar Cambios' : 'Crear Vehiculo'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|