erp-mecanicas-diesel-fronte.../src/pages/Vehicles.tsx
rckrdmrd abff318db4 Migración desde erp-mecanicas-diesel/frontend - Estándar multi-repo v2
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 08:11:27 -06:00

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