[SPRINT-2] feat: Implement Transport Orders UI MVP

- Add AsignarUnidadModal for unit/operator assignment
- Update OTList, OTDetail, OTForm components
- Create clientes feature module with service and hooks
- Create tarifas feature module with lane rate calculator
- Update OrdenesTransportePage with filters and map integration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-03 07:05:21 -06:00
parent 0bc16b52bf
commit f14e6112cb
12 changed files with 2002 additions and 344 deletions

View File

@ -0,0 +1,57 @@
import { api } from '@/services/api/axios-instance';
import type {
Cliente,
ClienteFilters,
ClientesResponse,
CreateClienteDto,
UpdateClienteDto,
} from '../types';
const BASE_URL = '/api/v1/clientes';
export const clientesApi = {
// Get all clientes with filters
getAll: async (filters?: ClienteFilters): Promise<ClientesResponse> => {
const params = new URLSearchParams();
if (filters?.search) params.append('search', filters.search);
if (filters?.tipo) params.append('tipo', filters.tipo);
if (filters?.estado) params.append('estado', filters.estado);
if (filters?.activo !== undefined) params.append('activo', String(filters.activo));
if (filters?.limit) params.append('limit', String(filters.limit));
if (filters?.offset) params.append('offset', String(filters.offset));
const response = await api.get<ClientesResponse>(`${BASE_URL}?${params.toString()}`);
return response.data;
},
// Get cliente by ID
getById: async (id: string): Promise<{ data: Cliente }> => {
const response = await api.get<{ data: Cliente }>(`${BASE_URL}/${id}`);
return response.data;
},
// Search clientes (for autocomplete)
search: async (query: string, limit = 10): Promise<{ data: Cliente[] }> => {
const response = await api.get<{ data: Cliente[] }>(
`${BASE_URL}/search?q=${encodeURIComponent(query)}&limit=${limit}`
);
return response.data;
},
// Create cliente
create: async (data: CreateClienteDto): Promise<{ data: Cliente }> => {
const response = await api.post<{ data: Cliente }>(BASE_URL, data);
return response.data;
},
// Update cliente
update: async (id: string, data: UpdateClienteDto): Promise<{ data: Cliente }> => {
const response = await api.patch<{ data: Cliente }>(`${BASE_URL}/${id}`, data);
return response.data;
},
// Delete cliente
delete: async (id: string): Promise<void> => {
await api.delete(`${BASE_URL}/${id}`);
},
};

View File

@ -0,0 +1,5 @@
// Types
export * from './types';
// API
export { clientesApi } from './api/clientes.api';

View File

@ -0,0 +1,82 @@
/**
* Cliente Types
*/
export enum TipoCliente {
SHIPPER = 'SHIPPER',
CONSIGNEE = 'CONSIGNEE',
SHIPPER_CONSIGNEE = 'SHIPPER_CONSIGNEE',
BROKER = 'BROKER',
}
export enum EstadoCliente {
ACTIVO = 'ACTIVO',
INACTIVO = 'INACTIVO',
SUSPENDIDO = 'SUSPENDIDO',
BLOQUEADO = 'BLOQUEADO',
}
export interface Cliente {
id: string;
tenantId: string;
codigo: string;
razonSocial: string;
nombreComercial?: string;
rfc?: string;
tipo: TipoCliente;
estado: EstadoCliente;
// Direccion fiscal
direccionFiscal?: string;
codigoPostal?: string;
ciudad?: string;
estadoRepublica?: string;
pais?: string;
// Contacto
telefono?: string;
email?: string;
contactoPrincipal?: string;
// Credito
creditoLimite?: number;
creditoUtilizado?: number;
diasCredito?: number;
// Configuracion
tipoTarifa?: string;
condicionesPago?: string;
observaciones?: string;
activo: boolean;
createdAt: string;
updatedAt: string;
}
export interface ClienteFilters {
search?: string;
tipo?: TipoCliente;
estado?: EstadoCliente;
activo?: boolean;
limit?: number;
offset?: number;
}
export interface ClientesResponse {
data: Cliente[];
total: number;
}
export interface CreateClienteDto {
razonSocial: string;
nombreComercial?: string;
rfc?: string;
tipo?: TipoCliente;
direccionFiscal?: string;
codigoPostal?: string;
ciudad?: string;
estadoRepublica?: string;
pais?: string;
telefono?: string;
email?: string;
contactoPrincipal?: string;
}
export interface UpdateClienteDto extends Partial<CreateClienteDto> {
estado?: EstadoCliente;
}

View File

@ -0,0 +1,416 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { unidadesApi } from '../../flota/api/unidades.api';
import { operadoresApi } from '../../flota/api/operadores.api';
import { viajesApi } from '../../viajes/api/viajes.api';
import { UnidadStatusBadge } from '../../flota/components/UnidadStatusBadge';
import { OperadorStatusBadge } from '../../flota/components/OperadorStatusBadge';
import type { Unidad, Operador } from '../../flota/types';
const asignacionSchema = z.object({
unidadId: z.string().min(1, 'Seleccione una unidad'),
operadorId: z.string().min(1, 'Seleccione un operador'),
fechaSalida: z.string().min(1, 'Fecha de salida es requerida'),
horaSalida: z.string().optional(),
fechaEntrega: z.string().optional(),
notas: z.string().optional(),
});
type AsignacionFormData = z.infer<typeof asignacionSchema>;
interface AsignarUnidadModalProps {
ordenId: string;
ordenNumero: string;
onClose: () => void;
onSuccess: () => void;
}
export function AsignarUnidadModal({
ordenId,
ordenNumero,
onClose,
onSuccess,
}: AsignarUnidadModalProps) {
const queryClient = useQueryClient();
const [step, setStep] = useState<'unidad' | 'operador' | 'fechas'>('unidad');
const [selectedUnidad, setSelectedUnidad] = useState<Unidad | null>(null);
const [selectedOperador, setSelectedOperador] = useState<Operador | null>(null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setValue,
} = useForm<AsignacionFormData>({
resolver: zodResolver(asignacionSchema),
defaultValues: {
fechaSalida: new Date().toISOString().split('T')[0],
},
});
// Fetch unidades disponibles
const { data: unidadesData, isLoading: loadingUnidades } = useQuery({
queryKey: ['unidades-disponibles'],
queryFn: () => unidadesApi.getDisponibles(),
});
// Fetch operadores disponibles
const { data: operadoresData, isLoading: loadingOperadores } = useQuery({
queryKey: ['operadores-disponibles'],
queryFn: () => operadoresApi.getDisponibles(),
enabled: step !== 'unidad',
});
const unidades = unidadesData?.data || [];
const operadores = operadoresData?.data || [];
// Mutation para crear viaje y asignar
const asignarMutation = useMutation({
mutationFn: async (data: AsignacionFormData) => {
// Crear viaje con la OT
const viajeData = {
ordenTransporteId: ordenId,
unidadId: data.unidadId,
operadorId: data.operadorId,
fechaSalidaProgramada: data.fechaSalida + (data.horaSalida ? `T${data.horaSalida}:00` : 'T08:00:00'),
fechaEntregaProgramada: data.fechaEntrega || undefined,
notas: data.notas,
};
return viajesApi.create(viajeData);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ordenes-transporte'] });
queryClient.invalidateQueries({ queryKey: ['viajes'] });
queryClient.invalidateQueries({ queryKey: ['unidades-disponibles'] });
queryClient.invalidateQueries({ queryKey: ['operadores-disponibles'] });
onSuccess();
},
});
const handleSelectUnidad = (unidad: Unidad) => {
setSelectedUnidad(unidad);
setValue('unidadId', unidad.id);
setStep('operador');
};
const handleSelectOperador = (operador: Operador) => {
setSelectedOperador(operador);
setValue('operadorId', operador.id);
setStep('fechas');
};
const onSubmit = async (data: AsignacionFormData) => {
await asignarMutation.mutateAsync(data);
};
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-screen items-end justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={onClose} />
<div className="inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:align-middle">
{/* Header */}
<div className="border-b border-gray-200 bg-gray-50 px-6 py-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">Asignar Unidad y Operador</h3>
<p className="text-sm text-gray-500">Orden: {ordenNumero}</p>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Steps Indicator */}
<div className="mt-4 flex items-center justify-center">
<div className="flex items-center">
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${
step === 'unidad' ? 'bg-primary-600 text-white' : selectedUnidad ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-600'
}`}
>
{selectedUnidad ? '1' : '1'}
</div>
<span className={`ml-2 text-sm ${step === 'unidad' ? 'font-medium text-primary-600' : 'text-gray-500'}`}>
Unidad
</span>
</div>
<div className="mx-4 h-0.5 w-12 bg-gray-200" />
<div className="flex items-center">
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${
step === 'operador' ? 'bg-primary-600 text-white' : selectedOperador ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-600'
}`}
>
2
</div>
<span className={`ml-2 text-sm ${step === 'operador' ? 'font-medium text-primary-600' : 'text-gray-500'}`}>
Operador
</span>
</div>
<div className="mx-4 h-0.5 w-12 bg-gray-200" />
<div className="flex items-center">
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${
step === 'fechas' ? 'bg-primary-600 text-white' : 'bg-gray-200 text-gray-600'
}`}
>
3
</div>
<span className={`ml-2 text-sm ${step === 'fechas' ? 'font-medium text-primary-600' : 'text-gray-500'}`}>
Fechas
</span>
</div>
</div>
</div>
{/* Content */}
<div className="max-h-[60vh] overflow-y-auto px-6 py-4">
{/* Step 1: Seleccionar Unidad */}
{step === 'unidad' && (
<div className="space-y-4">
<h4 className="font-medium text-gray-900">Seleccionar Unidad Disponible</h4>
{loadingUnidades ? (
<div className="py-8 text-center text-gray-500">
<svg className="mx-auto h-8 w-8 animate-spin text-primary-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<p className="mt-2">Cargando unidades...</p>
</div>
) : unidades.length === 0 ? (
<div className="py-8 text-center text-gray-500">
<svg className="mx-auto h-12 w-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 17a2 2 0 11-4 0 2 2 0 014 0zM19 17a2 2 0 11-4 0 2 2 0 014 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M13 16V6a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h1m8-1a1 1 0 01-1 1H9m4-1V8a1 1 0 011-1h2.586a1 1 0 01.707.293l3.414 3.414a1 1 0 01.293.707V16a1 1 0 01-1 1h-1m-6-1a1 1 0 001 1h1M5 17a2 2 0 104 0m-4 0a2 2 0 114 0m6 0a2 2 0 104 0m-4 0a2 2 0 114 0" />
</svg>
<p className="mt-2">No hay unidades disponibles</p>
</div>
) : (
<div className="space-y-2">
{unidades.map((unidad) => (
<div
key={unidad.id}
onClick={() => handleSelectUnidad(unidad)}
className={`cursor-pointer rounded-lg border p-4 transition-colors ${
selectedUnidad?.id === unidad.id
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 hover:border-primary-300 hover:bg-gray-50'
}`}
>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-gray-900">{unidad.numeroEconomico}</div>
<div className="text-sm text-gray-500">
{unidad.marca} {unidad.modelo} {unidad.anio} - {unidad.tipo?.replace(/_/g, ' ')}
</div>
<div className="text-sm text-gray-500">Placas: {unidad.placa || unidad.placas || '-'}</div>
</div>
<div className="text-right">
<UnidadStatusBadge estado={unidad.estado} size="sm" />
<div className="mt-1 text-sm text-gray-500">{unidad.odometroActual?.toLocaleString()} km</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Step 2: Seleccionar Operador */}
{step === 'operador' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-medium text-gray-900">Seleccionar Operador Disponible</h4>
<button
onClick={() => setStep('unidad')}
className="text-sm text-primary-600 hover:text-primary-700"
>
Cambiar unidad
</button>
</div>
{selectedUnidad && (
<div className="rounded-lg bg-gray-50 p-3 text-sm">
<span className="text-gray-500">Unidad seleccionada: </span>
<span className="font-medium text-gray-900">{selectedUnidad.numeroEconomico}</span>
</div>
)}
{loadingOperadores ? (
<div className="py-8 text-center text-gray-500">
<svg className="mx-auto h-8 w-8 animate-spin text-primary-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<p className="mt-2">Cargando operadores...</p>
</div>
) : operadores.length === 0 ? (
<div className="py-8 text-center text-gray-500">
<svg className="mx-auto h-12 w-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<p className="mt-2">No hay operadores disponibles</p>
</div>
) : (
<div className="space-y-2">
{operadores.map((operador) => (
<div
key={operador.id}
onClick={() => handleSelectOperador(operador)}
className={`cursor-pointer rounded-lg border p-4 transition-colors ${
selectedOperador?.id === operador.id
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 hover:border-primary-300 hover:bg-gray-50'
}`}
>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-gray-900">
{operador.nombre} {operador.apellidoPaterno} {operador.apellidoMaterno}
</div>
<div className="text-sm text-gray-500">
{operador.numeroEmpleado} - Licencia {operador.tipoLicencia}
</div>
{operador.telefono && (
<div className="text-sm text-gray-500">Tel: {operador.telefono}</div>
)}
</div>
<div className="text-right">
<OperadorStatusBadge estado={operador.estado} size="sm" />
<div className="mt-1 text-sm text-gray-500">{operador.totalViajes} viajes</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Step 3: Fechas y Confirmacion */}
{step === 'fechas' && (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-medium text-gray-900">Programar Viaje</h4>
<button
type="button"
onClick={() => setStep('operador')}
className="text-sm text-primary-600 hover:text-primary-700"
>
Cambiar operador
</button>
</div>
{/* Resumen de seleccion */}
<div className="rounded-lg bg-gray-50 p-4 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-500">Unidad:</span>
<span className="font-medium text-gray-900">{selectedUnidad?.numeroEconomico}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Operador:</span>
<span className="font-medium text-gray-900">
{selectedOperador?.nombre} {selectedOperador?.apellidoPaterno}
</span>
</div>
</div>
{/* Fecha y hora de salida */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Fecha de Salida *</label>
<input
type="date"
{...register('fechaSalida')}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
/>
{errors.fechaSalida && (
<p className="mt-1 text-sm text-red-600">{errors.fechaSalida.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Hora de Salida</label>
<input
type="time"
{...register('horaSalida')}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
/>
</div>
</div>
{/* Fecha de entrega */}
<div>
<label className="block text-sm font-medium text-gray-700">Fecha Estimada de Entrega</label>
<input
type="date"
{...register('fechaEntrega')}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
/>
</div>
{/* Notas */}
<div>
<label className="block text-sm font-medium text-gray-700">Notas</label>
<textarea
{...register('notas')}
rows={3}
placeholder="Instrucciones adicionales para el operador..."
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
/>
</div>
{/* Error de mutation */}
{asignarMutation.error && (
<div className="rounded-lg bg-red-50 p-3 text-sm text-red-700">
Error: {(asignarMutation.error as Error).message}
</div>
)}
</form>
)}
</div>
{/* Footer */}
<div className="border-t border-gray-200 bg-gray-50 px-6 py-4">
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-gray-700 hover:bg-gray-50"
>
Cancelar
</button>
{step === 'fechas' && (
<button
onClick={handleSubmit(onSubmit)}
disabled={isSubmitting || asignarMutation.isPending}
className="flex items-center gap-2 rounded-lg bg-primary-600 px-4 py-2 text-white hover:bg-primary-700 disabled:opacity-50"
>
{(isSubmitting || asignarMutation.isPending) ? (
<>
<svg className="h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Asignando...
</>
) : (
<>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Confirmar Asignacion
</>
)}
</button>
)}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,21 +1,61 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { ordenesTransporteApi } from '../api/ordenes-transporte.api';
import { OTStatusBadge } from './OTStatusBadge';
import { EstadoOrdenTransporte } from '../types';
import type { OrdenTransporte } from '../types';
interface OTDetailProps {
orden: OrdenTransporte;
onClose?: () => void;
onEdit?: () => void;
onAsignarUnidad?: () => void;
}
export function OTDetail({ orden, onClose, onEdit }: OTDetailProps) {
// Timeline de estados
const estadoTimeline: EstadoOrdenTransporte[] = [
EstadoOrdenTransporte.BORRADOR,
EstadoOrdenTransporte.PENDIENTE,
EstadoOrdenTransporte.CONFIRMADA,
EstadoOrdenTransporte.ASIGNADA,
EstadoOrdenTransporte.EN_TRANSITO,
EstadoOrdenTransporte.ENTREGADA,
EstadoOrdenTransporte.FACTURADA,
];
const estadoIndex: Record<EstadoOrdenTransporte, number> = {
[EstadoOrdenTransporte.BORRADOR]: 0,
[EstadoOrdenTransporte.PENDIENTE]: 1,
[EstadoOrdenTransporte.SOLICITADA]: 1,
[EstadoOrdenTransporte.CONFIRMADA]: 2,
[EstadoOrdenTransporte.ASIGNADA]: 3,
[EstadoOrdenTransporte.EN_PROCESO]: 4,
[EstadoOrdenTransporte.EN_TRANSITO]: 4,
[EstadoOrdenTransporte.COMPLETADA]: 5,
[EstadoOrdenTransporte.ENTREGADA]: 5,
[EstadoOrdenTransporte.FACTURADA]: 6,
[EstadoOrdenTransporte.CANCELADA]: -1,
};
const estadoLabels: Record<string, string> = {
BORRADOR: 'Borrador',
PENDIENTE: 'Pendiente',
CONFIRMADA: 'Confirmada',
ASIGNADA: 'Asignada',
EN_TRANSITO: 'En Transito',
ENTREGADA: 'Entregada',
FACTURADA: 'Facturada',
};
export function OTDetail({ orden, onClose, onEdit, onAsignarUnidad }: OTDetailProps) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const confirmarMutation = useMutation({
mutationFn: () => ordenesTransporteApi.confirmar(orden.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ordenes-transporte'] });
onClose?.();
},
});
@ -23,6 +63,7 @@ export function OTDetail({ orden, onClose, onEdit }: OTDetailProps) {
mutationFn: (motivo: string) => ordenesTransporteApi.cancelar(orden.id, motivo),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ordenes-transporte'] });
onClose?.();
},
});
@ -50,206 +91,397 @@ export function OTDetail({ orden, onClose, onEdit }: OTDetailProps) {
};
const canConfirm = orden.estado === 'PENDIENTE' || orden.estado === 'BORRADOR';
const canCancel = ['BORRADOR', 'PENDIENTE', 'CONFIRMADA', 'PROGRAMADA'].includes(orden.estado);
const canEdit = ['BORRADOR', 'PENDIENTE'].includes(orden.estado);
const canAsignar = orden.estado === 'CONFIRMADA';
const canCancel = ['BORRADOR', 'PENDIENTE', 'CONFIRMADA'].includes(orden.estado);
const canViewViaje = orden.estado === 'ASIGNADA' && orden.viajeId;
const currentStepIndex = estadoIndex[orden.estado] ?? -1;
const isCancelada = orden.estado === 'CANCELADA';
const handleConfirmar = async () => {
if (window.confirm('¿Confirmar esta orden de transporte?')) {
if (window.confirm('Confirmar esta orden de transporte?')) {
await confirmarMutation.mutateAsync();
}
};
const handleCancelar = async () => {
const motivo = window.prompt('Motivo de cancelación:');
const motivo = window.prompt('Motivo de cancelacion:');
if (motivo) {
await cancelarMutation.mutateAsync(motivo);
}
};
const handleVerViaje = () => {
if (orden.viajeId) {
navigate(`/viajes/${orden.viajeId}`);
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900">{orden.numero}</h2>
<div className="mt-1 flex items-center gap-3">
<OTStatusBadge estado={orden.estado} size="lg" />
<span className="text-gray-500">
{orden.modalidad} {orden.tipoCarga?.replace(/_/g, ' ')}
</span>
</div>
</div>
{onClose && (
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{/* Cliente */}
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 className="text-sm font-medium text-gray-500">Cliente</h3>
<div className="mt-2 text-lg font-medium text-gray-900">{orden.clienteNombre}</div>
{orden.referenciaCliente && (
<div className="mt-1 text-sm text-gray-500">Ref: {orden.referenciaCliente}</div>
)}
</div>
{/* Origen y Destino */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="rounded-lg border border-gray-200 p-4">
<h3 className="text-sm font-medium text-gray-500">Origen</h3>
<div className="mt-2 space-y-1">
<div className="font-medium text-gray-900">{orden.origenDireccion}</div>
<div className="text-gray-600">
{orden.origenCiudad}, {orden.origenEstado} {orden.origenCP}
<div className="max-h-[80vh] overflow-y-auto">
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900">{orden.numero || orden.codigo}</h2>
<div className="mt-1 flex items-center gap-3">
<OTStatusBadge estado={orden.estado} size="lg" />
<span className="text-gray-500">
{orden.modalidad || orden.modalidadServicio} - {orden.tipoCarga?.replace(/_/g, ' ')}
</span>
</div>
<div className="text-gray-500">{orden.origenPais}</div>
</div>
{onClose && (
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
<div className="rounded-lg border border-gray-200 p-4">
<h3 className="text-sm font-medium text-gray-500">Destino</h3>
<div className="mt-2 space-y-1">
<div className="font-medium text-gray-900">{orden.destinoDireccion}</div>
<div className="text-gray-600">
{orden.destinoCiudad}, {orden.destinoEstado} {orden.destinoCP}
{/* Timeline de Estados */}
{!isCancelada && (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 className="mb-4 text-sm font-medium text-gray-700">Progreso de la Orden</h3>
<div className="relative">
<div className="flex items-center justify-between">
{estadoTimeline.map((estado, index) => {
const isCompleted = index <= currentStepIndex;
const isCurrent = index === currentStepIndex;
return (
<div key={estado} className="flex flex-col items-center">
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${
isCompleted
? isCurrent
? 'bg-primary-600 text-white ring-4 ring-primary-100'
: 'bg-green-500 text-white'
: 'bg-gray-200 text-gray-500'
}`}
>
{isCompleted && !isCurrent ? (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
index + 1
)}
</div>
<span
className={`mt-2 text-xs ${
isCurrent ? 'font-semibold text-primary-600' : 'text-gray-500'
}`}
>
{estadoLabels[estado]}
</span>
</div>
);
})}
</div>
{/* Progress Line */}
<div className="absolute left-0 right-0 top-4 -z-10 h-0.5 bg-gray-200">
<div
className="h-full bg-green-500 transition-all"
style={{ width: `${(currentStepIndex / (estadoTimeline.length - 1)) * 100}%` }}
/>
</div>
</div>
</div>
)}
{/* Cancelada Alert */}
{isCancelada && (
<div className="rounded-lg border border-red-200 bg-red-50 p-4">
<div className="flex items-center gap-2 text-red-800">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span className="font-medium">Esta orden fue cancelada</span>
</div>
</div>
)}
{/* Cliente */}
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 className="text-sm font-medium text-gray-500">Cliente</h3>
<div className="mt-2 text-lg font-medium text-gray-900">{orden.clienteNombre || '-'}</div>
{orden.referenciaCliente && (
<div className="mt-1 text-sm text-gray-500">Ref: {orden.referenciaCliente}</div>
)}
</div>
{/* Mapa Placeholder y Origen/Destino */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* Mapa Placeholder */}
<div className="rounded-lg border border-gray-200 bg-gray-100 p-4">
<div className="flex h-48 flex-col items-center justify-center text-gray-400">
<svg className="h-16 w-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
<span className="mt-2 text-sm">Mapa de ruta</span>
{(orden.origenLatitud && orden.origenLongitud) ? (
<span className="mt-1 text-xs">Coordenadas disponibles</span>
) : (
<span className="mt-1 text-xs">Sin coordenadas</span>
)}
</div>
</div>
{/* Origen y Destino */}
<div className="space-y-4">
<div className="rounded-lg border border-green-200 bg-green-50 p-4">
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-green-500 text-white">
<span className="text-xs font-bold">O</span>
</div>
<h3 className="text-sm font-medium text-green-800">Origen</h3>
</div>
<div className="mt-2 space-y-1">
<div className="font-medium text-gray-900">{orden.origenDireccion}</div>
<div className="text-gray-600">
{orden.origenCiudad}{orden.origenEstado ? `, ${orden.origenEstado}` : ''} {orden.origenCP || orden.origenCodigoPostal}
</div>
<div className="text-gray-500">{orden.origenPais}</div>
{orden.origenContacto && (
<div className="text-sm text-gray-500">
<span className="font-medium">Contacto:</span> {orden.origenContacto} {orden.origenTelefono}
</div>
)}
</div>
</div>
<div className="rounded-lg border border-red-200 bg-red-50 p-4">
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-white">
<span className="text-xs font-bold">D</span>
</div>
<h3 className="text-sm font-medium text-red-800">Destino</h3>
</div>
<div className="mt-2 space-y-1">
<div className="font-medium text-gray-900">{orden.destinoDireccion}</div>
<div className="text-gray-600">
{orden.destinoCiudad}{orden.destinoEstado ? `, ${orden.destinoEstado}` : ''} {orden.destinoCP || orden.destinoCodigoPostal}
</div>
<div className="text-gray-500">{orden.destinoPais}</div>
{orden.destinoContacto && (
<div className="text-sm text-gray-500">
<span className="font-medium">Contacto:</span> {orden.destinoContacto} {orden.destinoTelefono}
</div>
)}
</div>
</div>
<div className="text-gray-500">{orden.destinoPais}</div>
</div>
</div>
</div>
{/* Fechas */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div>
<div className="text-sm text-gray-500">Recolección</div>
<div className="font-medium text-gray-900">{formatDate(orden.fechaRecoleccion)}</div>
</div>
<div>
<div className="text-sm text-gray-500">Entrega Estimada</div>
<div className="font-medium text-gray-900">{formatDate(orden.fechaEntregaEstimada)}</div>
</div>
<div>
<div className="text-sm text-gray-500">Creación</div>
<div className="font-medium text-gray-900">{formatDateTime(orden.createdAt)}</div>
</div>
<div>
<div className="text-sm text-gray-500">Última Actualización</div>
<div className="font-medium text-gray-900">{formatDateTime(orden.updatedAt)}</div>
</div>
</div>
{/* Carga */}
<div className="rounded-lg border border-gray-200 p-4">
<h3 className="mb-3 text-sm font-medium text-gray-500">Información de Carga</h3>
{/* Fechas */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{orden.pesoBruto && (
<div>
<div className="text-xs text-gray-500">Peso Bruto</div>
<div className="font-medium text-gray-900">{orden.pesoBruto.toLocaleString()} kg</div>
</div>
)}
{orden.pesoNeto && (
<div>
<div className="text-xs text-gray-500">Peso Neto</div>
<div className="font-medium text-gray-900">{orden.pesoNeto.toLocaleString()} kg</div>
</div>
)}
{orden.volumen && (
<div>
<div className="text-xs text-gray-500">Volumen</div>
<div className="font-medium text-gray-900">{orden.volumen.toLocaleString()} m³</div>
</div>
)}
{orden.cantidadBultos && (
<div>
<div className="text-xs text-gray-500">Bultos</div>
<div className="font-medium text-gray-900">{orden.cantidadBultos.toLocaleString()}</div>
</div>
)}
</div>
{orden.descripcionMercancia && (
<div className="mt-3">
<div className="text-xs text-gray-500">Descripción</div>
<div className="text-gray-900">{orden.descripcionMercancia}</div>
</div>
)}
{orden.tipoEquipo && (
<div className="mt-3">
<div className="text-xs text-gray-500">Tipo de Equipo Requerido</div>
<div className="font-medium text-gray-900">{orden.tipoEquipo.replace(/_/g, ' ')}</div>
</div>
)}
</div>
{/* Tarifa */}
<div className="rounded-lg border border-gray-200 bg-blue-50 p-4">
<h3 className="mb-3 text-sm font-medium text-gray-500">Tarifa</h3>
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
<div>
<div className="text-xs text-gray-500">Tarifa Base</div>
<div className="font-medium text-gray-900">{formatCurrency(orden.tarifaBase)}</div>
<div className="text-sm text-gray-500">Recoleccion</div>
<div className="font-medium text-gray-900">{formatDate(orden.fechaRecoleccion || orden.fechaRecoleccionProgramada)}</div>
</div>
<div>
<div className="text-xs text-gray-500">Tarifa Total</div>
<div className="text-xl font-bold text-primary-600">{formatCurrency(orden.tarifaTotal)}</div>
<div className="text-sm text-gray-500">Entrega Estimada</div>
<div className="font-medium text-gray-900">{formatDate(orden.fechaEntregaEstimada || orden.fechaEntregaProgramada)}</div>
</div>
{orden.valorMercancia && (
<div>
<div className="text-xs text-gray-500">Valor Mercancía</div>
<div className="font-medium text-gray-900">{formatCurrency(orden.valorMercancia)}</div>
</div>
)}
</div>
</div>
{/* Viaje Asignado */}
{orden.viajeId && (
<div className="rounded-lg border border-green-200 bg-green-50 p-4">
<h3 className="text-sm font-medium text-green-800">Viaje Asignado</h3>
<div className="mt-2 text-green-900">
Viaje: <span className="font-medium">{orden.viajeNumero || orden.viajeId}</span>
<div>
<div className="text-sm text-gray-500">Creacion</div>
<div className="font-medium text-gray-900">{formatDateTime(orden.createdAt)}</div>
</div>
<div>
<div className="text-sm text-gray-500">Ultima Actualizacion</div>
<div className="font-medium text-gray-900">{formatDateTime(orden.updatedAt)}</div>
</div>
</div>
)}
{/* Notas */}
{orden.notas && (
{/* Carga */}
<div className="rounded-lg border border-gray-200 p-4">
<h3 className="text-sm font-medium text-gray-500">Notas</h3>
<div className="mt-2 whitespace-pre-wrap text-gray-900">{orden.notas}</div>
<h3 className="mb-3 text-sm font-medium text-gray-500">Informacion de Carga</h3>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{(orden.pesoBruto || orden.pesoKg) && (
<div>
<div className="text-xs text-gray-500">Peso Bruto</div>
<div className="font-medium text-gray-900">{(orden.pesoBruto || orden.pesoKg)?.toLocaleString()} kg</div>
</div>
)}
{orden.pesoNeto && (
<div>
<div className="text-xs text-gray-500">Peso Neto</div>
<div className="font-medium text-gray-900">{orden.pesoNeto.toLocaleString()} kg</div>
</div>
)}
{(orden.volumen || orden.volumenM3) && (
<div>
<div className="text-xs text-gray-500">Volumen</div>
<div className="font-medium text-gray-900">{(orden.volumen || orden.volumenM3)?.toLocaleString()} m3</div>
</div>
)}
{(orden.cantidadBultos || orden.piezas) && (
<div>
<div className="text-xs text-gray-500">Bultos/Piezas</div>
<div className="font-medium text-gray-900">{(orden.cantidadBultos || orden.piezas)?.toLocaleString()}</div>
</div>
)}
{orden.pallets && (
<div>
<div className="text-xs text-gray-500">Pallets</div>
<div className="font-medium text-gray-900">{orden.pallets.toLocaleString()}</div>
</div>
)}
</div>
{(orden.descripcionMercancia || orden.descripcionCarga) && (
<div className="mt-3">
<div className="text-xs text-gray-500">Descripcion</div>
<div className="text-gray-900">{orden.descripcionMercancia || orden.descripcionCarga}</div>
</div>
)}
{orden.tipoEquipo && (
<div className="mt-3">
<div className="text-xs text-gray-500">Tipo de Equipo Requerido</div>
<div className="font-medium text-gray-900">{orden.tipoEquipo.replace(/_/g, ' ')}</div>
</div>
)}
{/* Requisitos especiales */}
<div className="mt-3 flex flex-wrap gap-2">
{orden.requiereTemperatura && (
<span className="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
Temp: {orden.temperaturaMin} a {orden.temperaturaMax} C
</span>
)}
{orden.requiereGps && (
<span className="rounded-full bg-purple-100 px-2.5 py-0.5 text-xs font-medium text-purple-800">
GPS Requerido
</span>
)}
{orden.requiereEscolta && (
<span className="rounded-full bg-orange-100 px-2.5 py-0.5 text-xs font-medium text-orange-800">
Escolta Requerida
</span>
)}
</div>
</div>
)}
{/* Actions */}
<div className="flex flex-wrap gap-3 border-t pt-4">
{canConfirm && (
<button
onClick={handleConfirmar}
disabled={confirmarMutation.isPending}
className="rounded-lg bg-green-600 px-4 py-2 text-white hover:bg-green-700 disabled:opacity-50"
>
{confirmarMutation.isPending ? 'Confirmando...' : 'Confirmar OT'}
</button>
{/* Tarifa */}
<div className="rounded-lg border border-gray-200 bg-blue-50 p-4">
<h3 className="mb-3 text-sm font-medium text-gray-500">Tarifa</h3>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div>
<div className="text-xs text-gray-500">Tarifa Base</div>
<div className="font-medium text-gray-900">{formatCurrency(orden.tarifaBase)}</div>
</div>
{orden.recargos > 0 && (
<div>
<div className="text-xs text-gray-500">Recargos</div>
<div className="font-medium text-gray-900">{formatCurrency(orden.recargos)}</div>
</div>
)}
{orden.descuentos > 0 && (
<div>
<div className="text-xs text-gray-500">Descuentos</div>
<div className="font-medium text-green-600">-{formatCurrency(orden.descuentos)}</div>
</div>
)}
<div>
<div className="text-xs text-gray-500">Total</div>
<div className="text-xl font-bold text-primary-600">{formatCurrency(orden.tarifaTotal || orden.total)}</div>
</div>
</div>
{(orden.valorMercancia || orden.valorDeclarado) && (
<div className="mt-3 border-t border-blue-200 pt-3">
<div className="text-xs text-gray-500">Valor Declarado de Mercancia</div>
<div className="font-medium text-gray-900">{formatCurrency(orden.valorMercancia || orden.valorDeclarado)}</div>
</div>
)}
</div>
{/* Viaje Asignado */}
{orden.viajeId && (
<div className="rounded-lg border border-green-200 bg-green-50 p-4">
<h3 className="text-sm font-medium text-green-800">Viaje Asignado</h3>
<div className="mt-2 flex items-center justify-between">
<div className="text-green-900">
Viaje: <span className="font-medium">{orden.viajeNumero || orden.viajeId}</span>
</div>
<button
onClick={handleVerViaje}
className="text-sm text-green-700 hover:text-green-900 hover:underline"
>
Ver viaje
</button>
</div>
</div>
)}
{onEdit && (
<button
onClick={onEdit}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-gray-700 hover:bg-gray-50"
>
Editar
</button>
)}
{canCancel && (
<button
onClick={handleCancelar}
disabled={cancelarMutation.isPending}
className="rounded-lg border border-red-300 bg-white px-4 py-2 text-red-600 hover:bg-red-50 disabled:opacity-50"
>
{cancelarMutation.isPending ? 'Cancelando...' : 'Cancelar'}
</button>
{/* Notas/Instrucciones */}
{(orden.notas || orden.instruccionesEspeciales || orden.observaciones) && (
<div className="rounded-lg border border-gray-200 p-4">
<h3 className="text-sm font-medium text-gray-500">Notas e Instrucciones</h3>
<div className="mt-2 whitespace-pre-wrap text-gray-900">
{orden.instruccionesEspeciales || orden.notas || orden.observaciones}
</div>
</div>
)}
{/* Actions */}
<div className="flex flex-wrap gap-3 border-t pt-4">
{canConfirm && (
<button
onClick={handleConfirmar}
disabled={confirmarMutation.isPending}
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-white hover:bg-green-700 disabled:opacity-50"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
{confirmarMutation.isPending ? 'Confirmando...' : 'Confirmar OT'}
</button>
)}
{canAsignar && onAsignarUnidad && (
<button
onClick={onAsignarUnidad}
className="flex items-center gap-2 rounded-lg bg-primary-600 px-4 py-2 text-white hover:bg-primary-700"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2" />
</svg>
Asignar Unidad
</button>
)}
{canViewViaje && (
<button
onClick={handleVerViaje}
className="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
Ver Viaje
</button>
)}
{canEdit && onEdit && (
<button
onClick={onEdit}
className="flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-gray-700 hover:bg-gray-50"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Editar
</button>
)}
{canCancel && (
<button
onClick={handleCancelar}
disabled={cancelarMutation.isPending}
className="flex items-center gap-2 rounded-lg border border-red-300 bg-white px-4 py-2 text-red-600 hover:bg-red-50 disabled:opacity-50"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
{cancelarMutation.isPending ? 'Cancelando...' : 'Cancelar'}
</button>
)}
</div>
</div>
</div>
);

View File

@ -1,51 +1,65 @@
import { useState, useEffect, useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ordenesTransporteApi } from '../api/ordenes-transporte.api';
import { clientesApi } from '../../clientes/api/clientes.api';
import { tarifasApi } from '../../tarifas/api/tarifas.api';
import { TipoCarga, ModalidadServicio, TipoEquipo } from '../types';
import type { OrdenTransporte, CreateOTDto, UpdateOTDto } from '../types';
import type { Cliente } from '../../clientes/types';
import type { CalculoTarifaResponse } from '../../tarifas/types';
const otSchema = z.object({
clienteId: z.string().min(1, 'Cliente es requerido'),
referenciaCliente: z.string().optional(),
modalidad: z.enum(['FTL', 'LTL', 'DEDICADO', 'EXPRESS', 'CONSOLIDADO']),
tipoCarga: z.enum(['GENERAL', 'REFRIGERADA', 'PELIGROSA', 'SOBREDIMENSIONADA', 'FRAGIL', 'GRANEL', 'LIQUIDOS', 'CONTENEDOR']),
tipoEquipo: z.enum(['CAJA_SECA', 'REFRIGERADO', 'PLATAFORMA', 'TANQUE', 'LOWBOY', 'PORTACONTENEDOR', 'TOLVA', 'GONDOLA']).optional(),
tipoCarga: z.enum(['GENERAL', 'REFRIGERADA', 'PELIGROSA', 'SOBREDIMENSIONADA', 'FRAGIL', 'GRANEL', 'LIQUIDOS', 'CONTENEDOR', 'AUTOMOVILES']),
tipoEquipo: z.enum(['CAJA_SECA', 'CAJA_REFRIGERADA', 'PLATAFORMA', 'TANQUE', 'PORTACONTENEDOR', 'TORTON', 'RABON', 'CAMIONETA']).optional().nullable(),
// Origen
origenDireccion: z.string().min(1, 'Dirección origen es requerida'),
origenDireccion: z.string().min(1, 'Direccion origen es requerida'),
origenCiudad: z.string().min(1, 'Ciudad origen es requerida'),
origenEstado: z.string().min(1, 'Estado origen es requerido'),
origenCP: z.string().optional(),
origenPais: z.string().default('MX'),
origenLatitud: z.number().optional(),
origenLongitud: z.number().optional(),
origenPais: z.string().default('Mexico'),
origenContacto: z.string().optional(),
origenTelefono: z.string().optional(),
// Destino
destinoDireccion: z.string().min(1, 'Dirección destino es requerida'),
destinoDireccion: z.string().min(1, 'Direccion destino es requerida'),
destinoCiudad: z.string().min(1, 'Ciudad destino es requerida'),
destinoEstado: z.string().min(1, 'Estado destino es requerido'),
destinoCP: z.string().optional(),
destinoPais: z.string().default('MX'),
destinoLatitud: z.number().optional(),
destinoLongitud: z.number().optional(),
destinoPais: z.string().default('Mexico'),
destinoContacto: z.string().optional(),
destinoTelefono: z.string().optional(),
// Fechas
fechaRecoleccion: z.string().min(1, 'Fecha de recolección es requerida'),
fechaRecoleccion: z.string().min(1, 'Fecha de recoleccion es requerida'),
fechaEntregaEstimada: z.string().optional(),
// Carga
pesoBruto: z.number().positive('Peso debe ser positivo').optional(),
pesoNeto: z.number().positive('Peso debe ser positivo').optional(),
volumen: z.number().positive('Volumen debe ser positivo').optional(),
cantidadBultos: z.number().int().positive('Cantidad debe ser positiva').optional(),
pesoBruto: z.number().positive('Peso debe ser positivo').optional().nullable(),
pesoNeto: z.number().positive('Peso debe ser positivo').optional().nullable(),
volumen: z.number().positive('Volumen debe ser positivo').optional().nullable(),
cantidadBultos: z.number().int().positive('Cantidad debe ser positiva').optional().nullable(),
pallets: z.number().int().positive('Cantidad debe ser positiva').optional().nullable(),
descripcionMercancia: z.string().optional(),
valorMercancia: z.number().positive('Valor debe ser positivo').optional(),
valorMercancia: z.number().positive('Valor debe ser positivo').optional().nullable(),
// Requisitos especiales
requiereTemperatura: z.boolean().default(false),
temperaturaMin: z.number().optional().nullable(),
temperaturaMax: z.number().optional().nullable(),
requiereGps: z.boolean().default(false),
requiereEscolta: z.boolean().default(false),
instruccionesEspeciales: z.string().optional(),
// Tarifa
tarifaBase: z.number().positive('Tarifa debe ser positiva').optional(),
tarifaTotal: z.number().positive('Tarifa debe ser positiva').optional(),
tarifaBase: z.number().positive('Tarifa debe ser positiva').optional().nullable(),
tarifaTotal: z.number().positive('Tarifa debe ser positiva').optional().nullable(),
moneda: z.string().default('MXN'),
notas: z.string().optional(),
@ -59,45 +73,72 @@ interface OTFormProps {
onCancel?: () => void;
}
// Estados de Mexico
const estadosMexico = [
'Aguascalientes', 'Baja California', 'Baja California Sur', 'Campeche', 'Chiapas',
'Chihuahua', 'Ciudad de Mexico', 'Coahuila', 'Colima', 'Durango', 'Estado de Mexico',
'Guanajuato', 'Guerrero', 'Hidalgo', 'Jalisco', 'Michoacan', 'Morelos', 'Nayarit',
'Nuevo Leon', 'Oaxaca', 'Puebla', 'Queretaro', 'Quintana Roo', 'San Luis Potosi',
'Sinaloa', 'Sonora', 'Tabasco', 'Tamaulipas', 'Tlaxcala', 'Veracruz', 'Yucatan', 'Zacatecas',
];
export function OTForm({ initialData, onSuccess, onCancel }: OTFormProps) {
const queryClient = useQueryClient();
const isEditing = !!initialData;
const [clienteSearch, setClienteSearch] = useState('');
const [showClienteDropdown, setShowClienteDropdown] = useState(false);
const [tarifaCalculada, setTarifaCalculada] = useState<CalculoTarifaResponse | null>(null);
const [calculandoTarifa, setCalculandoTarifa] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setValue,
watch,
trigger,
} = useForm<OTFormData>({
resolver: zodResolver(otSchema),
defaultValues: initialData
? {
clienteId: initialData.clienteId,
referenciaCliente: initialData.referenciaCliente || '',
modalidad: (initialData.modalidad || 'FTL') as 'FTL' | 'LTL' | 'DEDICADO' | 'EXPRESS' | 'CONSOLIDADO',
tipoCarga: initialData.tipoCarga as 'GENERAL' | 'REFRIGERADA' | 'PELIGROSA' | 'SOBREDIMENSIONADA' | 'FRAGIL' | 'GRANEL' | 'LIQUIDOS' | 'CONTENEDOR',
tipoEquipo: initialData.tipoEquipo as 'CAJA_SECA' | 'REFRIGERADO' | 'PLATAFORMA' | 'TANQUE' | 'LOWBOY' | 'PORTACONTENEDOR' | 'TOLVA' | 'GONDOLA' | undefined,
modalidad: (initialData.modalidad || initialData.modalidadServicio || 'FTL') as 'FTL' | 'LTL' | 'DEDICADO' | 'EXPRESS' | 'CONSOLIDADO',
tipoCarga: (initialData.tipoCarga || 'GENERAL') as 'GENERAL' | 'REFRIGERADA' | 'PELIGROSA' | 'SOBREDIMENSIONADA' | 'FRAGIL' | 'GRANEL' | 'LIQUIDOS' | 'CONTENEDOR' | 'AUTOMOVILES',
tipoEquipo: initialData.tipoEquipo as OTFormData['tipoEquipo'],
origenDireccion: initialData.origenDireccion,
origenCiudad: initialData.origenCiudad || '',
origenEstado: initialData.origenEstado || '',
origenCP: initialData.origenCP || '',
origenPais: initialData.origenPais || 'MX',
origenCP: initialData.origenCP || initialData.origenCodigoPostal || '',
origenPais: initialData.origenPais || 'Mexico',
origenContacto: initialData.origenContacto || '',
origenTelefono: initialData.origenTelefono || '',
destinoDireccion: initialData.destinoDireccion,
destinoCiudad: initialData.destinoCiudad || '',
destinoEstado: initialData.destinoEstado || '',
destinoCP: initialData.destinoCP || '',
destinoPais: initialData.destinoPais || 'MX',
fechaRecoleccion: initialData.fechaRecoleccion?.split('T')[0] || '',
fechaEntregaEstimada: initialData.fechaEntregaEstimada?.split('T')[0],
pesoBruto: initialData.pesoBruto,
destinoCP: initialData.destinoCP || initialData.destinoCodigoPostal || '',
destinoPais: initialData.destinoPais || 'Mexico',
destinoContacto: initialData.destinoContacto || '',
destinoTelefono: initialData.destinoTelefono || '',
fechaRecoleccion: initialData.fechaRecoleccion?.split('T')[0] || initialData.fechaRecoleccionProgramada?.split('T')[0] || '',
fechaEntregaEstimada: initialData.fechaEntregaEstimada?.split('T')[0] || initialData.fechaEntregaProgramada?.split('T')[0],
pesoBruto: initialData.pesoBruto || initialData.pesoKg,
pesoNeto: initialData.pesoNeto,
volumen: initialData.volumen,
cantidadBultos: initialData.cantidadBultos,
descripcionMercancia: initialData.descripcionMercancia || '',
valorMercancia: initialData.valorMercancia,
volumen: initialData.volumen || initialData.volumenM3,
cantidadBultos: initialData.cantidadBultos || initialData.piezas,
pallets: initialData.pallets,
descripcionMercancia: initialData.descripcionMercancia || initialData.descripcionCarga || '',
valorMercancia: initialData.valorMercancia || initialData.valorDeclarado,
requiereTemperatura: initialData.requiereTemperatura || false,
temperaturaMin: initialData.temperaturaMin,
temperaturaMax: initialData.temperaturaMax,
requiereGps: initialData.requiereGps || false,
requiereEscolta: initialData.requiereEscolta || false,
instruccionesEspeciales: initialData.instruccionesEspeciales || '',
tarifaBase: initialData.tarifaBase,
tarifaTotal: initialData.tarifaTotal,
tarifaTotal: initialData.tarifaTotal || initialData.total,
moneda: initialData.moneda || 'MXN',
notas: initialData.notas || '',
notas: initialData.notas || initialData.observaciones || '',
}
: {
clienteId: '',
@ -110,12 +151,75 @@ export function OTForm({ initialData, onSuccess, onCancel }: OTFormProps) {
fechaRecoleccion: '',
modalidad: 'FTL' as const,
tipoCarga: 'GENERAL' as const,
origenPais: 'MX',
destinoPais: 'MX',
origenPais: 'Mexico',
destinoPais: 'Mexico',
moneda: 'MXN',
requiereTemperatura: false,
requiereGps: false,
requiereEscolta: false,
},
});
// Watch fields for tarifa calculation
const origenCiudad = watch('origenCiudad');
const origenEstado = watch('origenEstado');
const destinoCiudad = watch('destinoCiudad');
const destinoEstado = watch('destinoEstado');
const clienteId = watch('clienteId');
const modalidad = watch('modalidad');
const tipoCarga = watch('tipoCarga');
const tipoEquipo = watch('tipoEquipo');
const pesoBruto = watch('pesoBruto');
const volumen = watch('volumen');
const requiereTemperatura = watch('requiereTemperatura');
// Search clientes
const { data: clientesData, isLoading: loadingClientes } = useQuery({
queryKey: ['clientes-search', clienteSearch],
queryFn: () => clientesApi.search(clienteSearch, 10),
enabled: clienteSearch.length >= 2,
});
const clientes = clientesData?.data || [];
// Set initial cliente name if editing
useEffect(() => {
if (initialData?.clienteNombre) {
setClienteSearch(initialData.clienteNombre);
}
}, [initialData]);
// Calculate tarifa when route changes
const calcularTarifa = useCallback(async () => {
if (!origenCiudad || !origenEstado || !destinoCiudad || !destinoEstado) {
return;
}
setCalculandoTarifa(true);
try {
const response = await tarifasApi.calcular({
origenCiudad,
origenEstado,
destinoCiudad,
destinoEstado,
clienteId: clienteId || undefined,
modalidad,
tipoCarga,
tipoEquipo: tipoEquipo || undefined,
pesoKg: pesoBruto || undefined,
volumenM3: volumen || undefined,
});
setTarifaCalculada(response);
setValue('tarifaBase', response.tarifaBase);
setValue('tarifaTotal', response.total);
} catch (error) {
console.error('Error calculando tarifa:', error);
setTarifaCalculada(null);
} finally {
setCalculandoTarifa(false);
}
}, [origenCiudad, origenEstado, destinoCiudad, destinoEstado, clienteId, modalidad, tipoCarga, tipoEquipo, pesoBruto, volumen, setValue]);
const createMutation = useMutation({
mutationFn: (data: CreateOTDto) => ordenesTransporteApi.create(data),
onSuccess: (response) => {
@ -132,6 +236,13 @@ export function OTForm({ initialData, onSuccess, onCancel }: OTFormProps) {
},
});
const handleSelectCliente = (cliente: Cliente) => {
setValue('clienteId', cliente.id);
setClienteSearch(cliente.razonSocial);
setShowClienteDropdown(false);
trigger('clienteId');
};
const onSubmit = async (data: OTFormData) => {
const dto = {
...data,
@ -139,6 +250,16 @@ export function OTForm({ initialData, onSuccess, onCancel }: OTFormProps) {
modalidadServicio: data.modalidad as ModalidadServicio,
modalidad: data.modalidad as ModalidadServicio,
tipoEquipo: data.tipoEquipo as TipoEquipo | undefined,
pesoBruto: data.pesoBruto || undefined,
pesoNeto: data.pesoNeto || undefined,
volumen: data.volumen || undefined,
cantidadBultos: data.cantidadBultos || undefined,
pallets: data.pallets || undefined,
valorMercancia: data.valorMercancia || undefined,
temperaturaMin: data.temperaturaMin || undefined,
temperaturaMax: data.temperaturaMax || undefined,
tarifaBase: data.tarifaBase || undefined,
tarifaTotal: data.tarifaTotal || undefined,
};
if (isEditing) {
await updateMutation.mutateAsync(dto as UpdateOTDto);
@ -147,21 +268,64 @@ export function OTForm({ initialData, onSuccess, onCancel }: OTFormProps) {
}
};
const formatCurrency = (amount?: number) => {
if (amount === undefined) return '-';
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
}).format(amount);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Cliente y Referencia */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<div className="relative">
<label className="block text-sm font-medium text-gray-700">Cliente *</label>
<input
type="text"
{...register('clienteId')}
value={clienteSearch}
onChange={(e) => {
setClienteSearch(e.target.value);
setShowClienteDropdown(true);
if (!e.target.value) {
setValue('clienteId', '');
}
}}
onFocus={() => setShowClienteDropdown(true)}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
placeholder="ID del cliente"
placeholder="Buscar cliente..."
/>
<input type="hidden" {...register('clienteId')} />
{errors.clienteId && (
<p className="mt-1 text-sm text-red-600">{errors.clienteId.message}</p>
)}
{/* Dropdown de clientes */}
{showClienteDropdown && clienteSearch.length >= 2 && (
<div className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-gray-200 bg-white shadow-lg">
{loadingClientes ? (
<div className="px-4 py-2 text-sm text-gray-500">Buscando...</div>
) : clientes.length === 0 ? (
<div className="px-4 py-2 text-sm text-gray-500">No se encontraron clientes</div>
) : (
clientes.map((cliente) => (
<div
key={cliente.id}
onClick={() => handleSelectCliente(cliente)}
className="cursor-pointer px-4 py-2 hover:bg-gray-100"
>
<div className="font-medium text-gray-900">{cliente.razonSocial}</div>
{cliente.nombreComercial && (
<div className="text-sm text-gray-500">{cliente.nombreComercial}</div>
)}
{cliente.rfc && (
<div className="text-xs text-gray-400">RFC: {cliente.rfc}</div>
)}
</div>
))
)}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Referencia Cliente</label>
@ -169,6 +333,7 @@ export function OTForm({ initialData, onSuccess, onCancel }: OTFormProps) {
type="text"
{...register('referenciaCliente')}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
placeholder="Numero de pedido, PO, etc."
/>
</div>
</div>
@ -198,10 +363,11 @@ export function OTForm({ initialData, onSuccess, onCancel }: OTFormProps) {
<option value="REFRIGERADA">Refrigerada</option>
<option value="PELIGROSA">Peligrosa</option>
<option value="SOBREDIMENSIONADA">Sobredimensionada</option>
<option value="FRAGIL">Frágil</option>
<option value="FRAGIL">Fragil</option>
<option value="GRANEL">Granel</option>
<option value="LIQUIDOS">Líquidos</option>
<option value="LIQUIDOS">Liquidos</option>
<option value="CONTENEDOR">Contenedor</option>
<option value="AUTOMOVILES">Automoviles</option>
</select>
</div>
<div>
@ -212,27 +378,31 @@ export function OTForm({ initialData, onSuccess, onCancel }: OTFormProps) {
>
<option value="">Seleccionar...</option>
<option value="CAJA_SECA">Caja Seca</option>
<option value="REFRIGERADO">Refrigerado</option>
<option value="CAJA_REFRIGERADA">Caja Refrigerada</option>
<option value="PLATAFORMA">Plataforma</option>
<option value="TANQUE">Tanque</option>
<option value="LOWBOY">Lowboy</option>
<option value="PORTACONTENEDOR">Portacontenedor</option>
<option value="TOLVA">Tolva</option>
<option value="GONDOLA">Góndola</option>
<option value="TORTON">Torton</option>
<option value="RABON">Rabon</option>
<option value="CAMIONETA">Camioneta</option>
</select>
</div>
</div>
{/* Origen */}
<fieldset className="rounded-lg border border-gray-200 p-4">
<legend className="px-2 text-sm font-medium text-gray-700">Origen</legend>
<legend className="flex items-center gap-2 px-2 text-sm font-medium text-gray-700">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-green-500 text-xs text-white">O</span>
Origen
</legend>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700">Dirección *</label>
<label className="block text-sm font-medium text-gray-700">Direccion *</label>
<input
type="text"
{...register('origenDireccion')}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
placeholder="Calle, numero, colonia..."
/>
{errors.origenDireccion && (
<p className="mt-1 text-sm text-red-600">{errors.origenDireccion.message}</p>
@ -245,44 +415,76 @@ export function OTForm({ initialData, onSuccess, onCancel }: OTFormProps) {
{...register('origenCiudad')}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
/>
{errors.origenCiudad && (
<p className="mt-1 text-sm text-red-600">{errors.origenCiudad.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Estado *</label>
<input
type="text"
<select
{...register('origenEstado')}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
/>
>
<option value="">Seleccionar...</option>
{estadosMexico.map((estado) => (
<option key={estado} value={estado}>{estado}</option>
))}
</select>
{errors.origenEstado && (
<p className="mt-1 text-sm text-red-600">{errors.origenEstado.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Código Postal</label>
<label className="block text-sm font-medium text-gray-700">Codigo Postal</label>
<input
type="text"
{...register('origenCP')}
maxLength={5}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">País</label>
<label className="block text-sm font-medium text-gray-700">Pais</label>
<input
type="text"
{...register('origenPais')}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Contacto</label>
<input
type="text"
{...register('origenContacto')}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
placeholder="Nombre del contacto"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Telefono</label>
<input
type="tel"
{...register('origenTelefono')}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
/>
</div>
</div>
</fieldset>
{/* Destino */}
<fieldset className="rounded-lg border border-gray-200 p-4">
<legend className="px-2 text-sm font-medium text-gray-700">Destino</legend>
<legend className="flex items-center gap-2 px-2 text-sm font-medium text-gray-700">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-xs text-white">D</span>
Destino
</legend>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700">Dirección *</label>
<label className="block text-sm font-medium text-gray-700">Direccion *</label>
<input
type="text"
{...register('destinoDireccion')}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
placeholder="Calle, numero, colonia..."
/>
{errors.destinoDireccion && (
<p className="mt-1 text-sm text-red-600">{errors.destinoDireccion.message}</p>
@ -295,38 +497,66 @@ export function OTForm({ initialData, onSuccess, onCancel }: OTFormProps) {
{...register('destinoCiudad')}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
/>
{errors.destinoCiudad && (
<p className="mt-1 text-sm text-red-600">{errors.destinoCiudad.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Estado *</label>
<input
type="text"
<select
{...register('destinoEstado')}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
/>
>
<option value="">Seleccionar...</option>
{estadosMexico.map((estado) => (
<option key={estado} value={estado}>{estado}</option>
))}
</select>
{errors.destinoEstado && (
<p className="mt-1 text-sm text-red-600">{errors.destinoEstado.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Código Postal</label>
<label className="block text-sm font-medium text-gray-700">Codigo Postal</label>
<input
type="text"
{...register('destinoCP')}
maxLength={5}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">País</label>
<label className="block text-sm font-medium text-gray-700">Pais</label>
<input
type="text"
{...register('destinoPais')}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Contacto</label>
<input
type="text"
{...register('destinoContacto')}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
placeholder="Nombre del contacto"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Telefono</label>
<input
type="tel"
{...register('destinoTelefono')}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
/>
</div>
</div>
</fieldset>
{/* Fechas */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700">Fecha Recolección *</label>
<label className="block text-sm font-medium text-gray-700">Fecha Recoleccion *</label>
<input
type="date"
{...register('fechaRecoleccion')}
@ -348,8 +578,8 @@ export function OTForm({ initialData, onSuccess, onCancel }: OTFormProps) {
{/* Carga */}
<fieldset className="rounded-lg border border-gray-200 p-4">
<legend className="px-2 text-sm font-medium text-gray-700">Información de Carga</legend>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<legend className="px-2 text-sm font-medium text-gray-700">Informacion de Carga</legend>
<div className="grid grid-cols-2 gap-4 md:grid-cols-5">
<div>
<label className="block text-sm font-medium text-gray-700">Peso Bruto (kg)</label>
<input
@ -369,7 +599,7 @@ export function OTForm({ initialData, onSuccess, onCancel }: OTFormProps) {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Volumen (m³)</label>
<label className="block text-sm font-medium text-gray-700">Volumen (m3)</label>
<input
type="number"
step="0.01"
@ -378,77 +608,222 @@ export function OTForm({ initialData, onSuccess, onCancel }: OTFormProps) {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Cantidad Bultos</label>
<label className="block text-sm font-medium text-gray-700">Bultos</label>
<input
type="number"
{...register('cantidadBultos', { valueAsNumber: true })}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Pallets</label>
<input
type="number"
{...register('pallets', { valueAsNumber: true })}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
/>
</div>
<div className="md:col-span-3">
<label className="block text-sm font-medium text-gray-700">Descripción Mercancía</label>
<label className="block text-sm font-medium text-gray-700">Descripcion Mercancia</label>
<input
type="text"
{...register('descripcionMercancia')}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
placeholder="Descripcion general de la carga..."
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700">Valor Mercancia</label>
<div className="relative mt-1">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
step="0.01"
{...register('valorMercancia', { valueAsNumber: true })}
className="block w-full rounded-lg border border-gray-300 pl-7 pr-3 py-2 focus:border-primary-500 focus:outline-none"
/>
</div>
</div>
</div>
</fieldset>
{/* Requisitos Especiales */}
<fieldset className="rounded-lg border border-gray-200 p-4">
<legend className="px-2 text-sm font-medium text-gray-700">Requisitos Especiales</legend>
<div className="space-y-4">
<div className="flex flex-wrap gap-6">
<label className="flex items-center gap-2">
<input
type="checkbox"
{...register('requiereTemperatura')}
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm text-gray-700">Requiere control de temperatura</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
{...register('requiereGps')}
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm text-gray-700">Requiere GPS</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
{...register('requiereEscolta')}
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm text-gray-700">Requiere escolta</span>
</label>
</div>
{requiereTemperatura && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Temperatura Minima (C)</label>
<input
type="number"
{...register('temperaturaMin', { valueAsNumber: true })}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Temperatura Maxima (C)</label>
<input
type="number"
{...register('temperaturaMax', { valueAsNumber: true })}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
/>
</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700">Valor Mercancía</label>
<input
type="number"
step="0.01"
{...register('valorMercancia', { valueAsNumber: true })}
<label className="block text-sm font-medium text-gray-700">Instrucciones Especiales</label>
<textarea
{...register('instruccionesEspeciales')}
rows={2}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
placeholder="Horarios de entrega, requisitos de carga/descarga, etc."
/>
</div>
</div>
</fieldset>
{/* Tarifa */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<label className="block text-sm font-medium text-gray-700">Tarifa Base</label>
<input
type="number"
step="0.01"
{...register('tarifaBase', { valueAsNumber: true })}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
/>
<fieldset className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<legend className="px-2 text-sm font-medium text-gray-700">Tarifa</legend>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">
{origenCiudad && destinoCiudad
? `Ruta: ${origenCiudad}, ${origenEstado} a ${destinoCiudad}, ${destinoEstado}`
: 'Complete origen y destino para calcular tarifa'}
</span>
<button
type="button"
onClick={calcularTarifa}
disabled={!origenCiudad || !origenEstado || !destinoCiudad || !destinoEstado || calculandoTarifa}
className="flex items-center gap-2 rounded-lg bg-primary-600 px-3 py-1.5 text-sm text-white hover:bg-primary-700 disabled:opacity-50"
>
{calculandoTarifa ? (
<>
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Calculando...
</>
) : (
<>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
Calcular Tarifa
</>
)}
</button>
</div>
{tarifaCalculada && (
<div className="rounded-lg border border-blue-200 bg-white p-3">
<div className="grid grid-cols-2 gap-2 text-sm">
{tarifaCalculada.distanciaKm && (
<div className="text-gray-500">Distancia: {tarifaCalculada.distanciaKm} km</div>
)}
<div className="text-gray-500">Tarifa Base: {formatCurrency(tarifaCalculada.tarifaBase)}</div>
{tarifaCalculada.fuelSurcharge > 0 && (
<div className="text-gray-500">Fuel Surcharge: {formatCurrency(tarifaCalculada.fuelSurcharge)}</div>
)}
{tarifaCalculada.otrosRecargos > 0 && (
<div className="text-gray-500">Otros Recargos: {formatCurrency(tarifaCalculada.otrosRecargos)}</div>
)}
</div>
<div className="mt-2 flex justify-between border-t border-blue-100 pt-2">
<span className="font-medium text-gray-700">Total Estimado:</span>
<span className="text-lg font-bold text-primary-600">{formatCurrency(tarifaCalculada.total)}</span>
</div>
</div>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<label className="block text-sm font-medium text-gray-700">Tarifa Base</label>
<div className="relative mt-1">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
step="0.01"
{...register('tarifaBase', { valueAsNumber: true })}
className="block w-full rounded-lg border border-gray-300 pl-7 pr-3 py-2 focus:border-primary-500 focus:outline-none"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Tarifa Total</label>
<div className="relative mt-1">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
step="0.01"
{...register('tarifaTotal', { valueAsNumber: true })}
className="block w-full rounded-lg border border-gray-300 pl-7 pr-3 py-2 focus:border-primary-500 focus:outline-none"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Moneda</label>
<select
{...register('moneda')}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
>
<option value="MXN">MXN</option>
<option value="USD">USD</option>
</select>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Tarifa Total</label>
<input
type="number"
step="0.01"
{...register('tarifaTotal', { valueAsNumber: true })}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Moneda</label>
<select
{...register('moneda')}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
>
<option value="MXN">MXN</option>
<option value="USD">USD</option>
</select>
</div>
</div>
</fieldset>
{/* Notas */}
<div>
<label className="block text-sm font-medium text-gray-700">Notas</label>
<label className="block text-sm font-medium text-gray-700">Notas Adicionales</label>
<textarea
{...register('notas')}
rows={3}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
placeholder="Observaciones generales..."
/>
</div>
{/* Error de mutation */}
{(createMutation.error || updateMutation.error) && (
<div className="rounded-lg bg-red-50 p-3 text-sm text-red-700">
Error: {((createMutation.error || updateMutation.error) as Error).message}
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3">
<div className="flex justify-end gap-3 border-t pt-4">
{onCancel && (
<button
type="button"
@ -461,9 +836,24 @@ export function OTForm({ initialData, onSuccess, onCancel }: OTFormProps) {
<button
type="submit"
disabled={isSubmitting}
className="rounded-lg bg-primary-600 px-4 py-2 text-white hover:bg-primary-700 disabled:opacity-50"
className="flex items-center gap-2 rounded-lg bg-primary-600 px-4 py-2 text-white hover:bg-primary-700 disabled:opacity-50"
>
{isSubmitting ? 'Guardando...' : isEditing ? 'Actualizar' : 'Crear'}
{isSubmitting ? (
<>
<svg className="h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Guardando...
</>
) : (
<>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
{isEditing ? 'Actualizar' : 'Crear Orden'}
</>
)}
</button>
</div>
</form>

View File

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ordenesTransporteApi } from '../api/ordenes-transporte.api';
import { OTStatusBadge } from './OTStatusBadge';
@ -11,19 +11,26 @@ interface OTListProps {
export function OTList({ onSelect, filters: externalFilters }: OTListProps) {
const [page, setPage] = useState(1);
const [searchText, setSearchText] = useState('');
const [estadoFilter, setEstadoFilter] = useState<EstadoOrdenTransporte | ''>('');
const [tipoCargaFilter, setTipoCargaFilter] = useState<TipoCarga | ''>('');
const [modalidadFilter, setModalidadFilter] = useState<ModalidadServicio | ''>('');
const [fechaDesde, setFechaDesde] = useState('');
const [fechaHasta, setFechaHasta] = useState('');
const [showFilters, setShowFilters] = useState(false);
const limit = 10;
const filters: OTFilters = {
const filters: OTFilters = useMemo(() => ({
...externalFilters,
search: searchText || undefined,
estado: estadoFilter || undefined,
tipoCarga: tipoCargaFilter || undefined,
modalidad: modalidadFilter || undefined,
fechaDesde: fechaDesde || undefined,
fechaHasta: fechaHasta || undefined,
limit,
offset: (page - 1) * limit,
};
}), [externalFilters, searchText, estadoFilter, tipoCargaFilter, modalidadFilter, fechaDesde, fechaHasta, page]);
const { data, isLoading, error } = useQuery({
queryKey: ['ordenes-transporte', filters],
@ -47,62 +54,197 @@ export function OTList({ onSelect, filters: externalFilters }: OTListProps) {
}).format(amount);
};
const clearFilters = () => {
setSearchText('');
setEstadoFilter('');
setTipoCargaFilter('');
setModalidadFilter('');
setFechaDesde('');
setFechaHasta('');
setPage(1);
};
const hasActiveFilters = searchText || estadoFilter || tipoCargaFilter || modalidadFilter || fechaDesde || fechaHasta;
if (error) {
return (
<div className="rounded-lg bg-red-50 p-4 text-red-700">
Error al cargar órdenes de transporte: {(error as Error).message}
Error al cargar ordenes de transporte: {(error as Error).message}
</div>
);
}
return (
<div className="space-y-4">
{/* Filters */}
{/* Search and Filter Toggle */}
<div className="flex flex-wrap items-center gap-4">
<select
value={estadoFilter}
onChange={(e) => setEstadoFilter(e.target.value as EstadoOrdenTransporte | '')}
className="rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
{/* Search Input */}
<div className="relative flex-1 min-w-[200px]">
<input
type="text"
value={searchText}
onChange={(e) => {
setSearchText(e.target.value);
setPage(1);
}}
placeholder="Buscar por numero, cliente, referencia..."
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-4 focus:border-primary-500 focus:outline-none"
/>
<svg
className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
{/* Filter Toggle Button */}
<button
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center gap-2 rounded-lg border px-4 py-2 ${
hasActiveFilters
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
<option value="">Todos los estados</option>
<option value="BORRADOR">Borrador</option>
<option value="PENDIENTE">Pendiente</option>
<option value="CONFIRMADA">Confirmada</option>
<option value="PROGRAMADA">Programada</option>
<option value="EN_PROCESO">En Proceso</option>
<option value="COMPLETADA">Completada</option>
<option value="FACTURADA">Facturada</option>
<option value="CANCELADA">Cancelada</option>
</select>
<select
value={tipoCargaFilter}
onChange={(e) => setTipoCargaFilter(e.target.value as TipoCarga | '')}
className="rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
>
<option value="">Todos los tipos de carga</option>
<option value="GENERAL">General</option>
<option value="REFRIGERADA">Refrigerada</option>
<option value="PELIGROSA">Peligrosa</option>
<option value="SOBREDIMENSIONADA">Sobredimensionada</option>
<option value="FRAGIL">Frágil</option>
<option value="GRANEL">Granel</option>
<option value="LIQUIDOS">Líquidos</option>
<option value="CONTENEDOR">Contenedor</option>
</select>
<select
value={modalidadFilter}
onChange={(e) => setModalidadFilter(e.target.value as ModalidadServicio | '')}
className="rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
>
<option value="">Todas las modalidades</option>
<option value="FTL">FTL (Full Truck Load)</option>
<option value="LTL">LTL (Less Than Truck)</option>
<option value="DEDICADO">Dedicado</option>
<option value="EXPRESS">Express</option>
<option value="CONSOLIDADO">Consolidado</option>
</select>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
/>
</svg>
Filtros
{hasActiveFilters && (
<span className="rounded-full bg-primary-600 px-2 py-0.5 text-xs text-white">
Activos
</span>
)}
</button>
{/* Clear Filters */}
{hasActiveFilters && (
<button
onClick={clearFilters}
className="text-sm text-gray-500 hover:text-gray-700"
>
Limpiar filtros
</button>
)}
</div>
{/* Expanded Filters */}
{showFilters && (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
{/* Estado */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Estado</label>
<select
value={estadoFilter}
onChange={(e) => {
setEstadoFilter(e.target.value as EstadoOrdenTransporte | '');
setPage(1);
}}
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
>
<option value="">Todos</option>
<option value="BORRADOR">Borrador</option>
<option value="PENDIENTE">Pendiente</option>
<option value="SOLICITADA">Solicitada</option>
<option value="CONFIRMADA">Confirmada</option>
<option value="ASIGNADA">Asignada</option>
<option value="EN_PROCESO">En Proceso</option>
<option value="EN_TRANSITO">En Transito</option>
<option value="COMPLETADA">Completada</option>
<option value="ENTREGADA">Entregada</option>
<option value="FACTURADA">Facturada</option>
<option value="CANCELADA">Cancelada</option>
</select>
</div>
{/* Tipo de Carga */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Tipo de Carga</label>
<select
value={tipoCargaFilter}
onChange={(e) => {
setTipoCargaFilter(e.target.value as TipoCarga | '');
setPage(1);
}}
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
>
<option value="">Todos</option>
<option value="GENERAL">General</option>
<option value="REFRIGERADA">Refrigerada</option>
<option value="PELIGROSA">Peligrosa</option>
<option value="SOBREDIMENSIONADA">Sobredimensionada</option>
<option value="FRAGIL">Fragil</option>
<option value="GRANEL">Granel</option>
<option value="LIQUIDOS">Liquidos</option>
<option value="CONTENEDOR">Contenedor</option>
</select>
</div>
{/* Modalidad */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Modalidad</label>
<select
value={modalidadFilter}
onChange={(e) => {
setModalidadFilter(e.target.value as ModalidadServicio | '');
setPage(1);
}}
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
>
<option value="">Todas</option>
<option value="FTL">FTL (Full Truck Load)</option>
<option value="LTL">LTL (Less Than Truck)</option>
<option value="DEDICADO">Dedicado</option>
<option value="EXPRESS">Express</option>
<option value="CONSOLIDADO">Consolidado</option>
</select>
</div>
{/* Fecha Desde */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Desde</label>
<input
type="date"
value={fechaDesde}
onChange={(e) => {
setFechaDesde(e.target.value);
setPage(1);
}}
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
/>
</div>
{/* Fecha Hasta */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Hasta</label>
<input
type="date"
value={fechaHasta}
onChange={(e) => {
setFechaHasta(e.target.value);
setPage(1);
}}
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
/>
</div>
</div>
</div>
)}
{/* Table */}
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
<table className="min-w-full divide-y divide-gray-200">
@ -118,7 +260,7 @@ export function OTList({ onSelect, filters: externalFilters }: OTListProps) {
Origen / Destino
</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Fecha Recolección
Fecha Recoleccion
</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Estado
@ -132,13 +274,32 @@ export function OTList({ onSelect, filters: externalFilters }: OTListProps) {
{isLoading ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
Cargando órdenes de transporte...
<div className="flex items-center justify-center gap-2">
<svg className="h-5 w-5 animate-spin text-primary-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Cargando ordenes de transporte...
</div>
</td>
</tr>
) : ordenes.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
No hay órdenes de transporte para mostrar
<div className="flex flex-col items-center gap-2">
<svg className="h-12 w-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<span>No hay ordenes de transporte para mostrar</span>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="text-sm text-primary-600 hover:text-primary-700"
>
Limpiar filtros
</button>
)}
</div>
</td>
</tr>
) : (
@ -146,36 +307,39 @@ export function OTList({ onSelect, filters: externalFilters }: OTListProps) {
<tr
key={ot.id}
onClick={() => onSelect?.(ot)}
className="cursor-pointer hover:bg-gray-50"
className="cursor-pointer hover:bg-gray-50 transition-colors"
>
<td className="whitespace-nowrap px-4 py-3">
<div className="font-medium text-gray-900">{ot.numero}</div>
<div className="font-medium text-gray-900">{ot.numero || ot.codigo}</div>
<div className="text-sm text-gray-500">
{ot.modalidad} {ot.tipoCarga?.replace(/_/g, ' ')}
{ot.modalidad || ot.modalidadServicio} - {ot.tipoCarga?.replace(/_/g, ' ')}
</div>
</td>
<td className="whitespace-nowrap px-4 py-3">
<div className="text-sm text-gray-900">{ot.clienteNombre}</div>
<div className="text-sm text-gray-900">{ot.clienteNombre || '-'}</div>
{ot.referenciaCliente && (
<div className="text-xs text-gray-500">Ref: {ot.referenciaCliente}</div>
)}
</td>
<td className="px-4 py-3">
<div className="text-sm text-gray-900">
{ot.origenCiudad}, {ot.origenEstado}
{ot.origenCiudad}{ot.origenEstado ? `, ${ot.origenEstado}` : ''}
</div>
<div className="text-sm text-gray-500">
{ot.destinoCiudad}, {ot.destinoEstado}
<div className="flex items-center text-sm text-gray-500">
<svg className="mr-1 h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
{ot.destinoCiudad}{ot.destinoEstado ? `, ${ot.destinoEstado}` : ''}
</div>
</td>
<td className="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
{formatDate(ot.fechaRecoleccion)}
{formatDate(ot.fechaRecoleccion || ot.fechaRecoleccionProgramada)}
</td>
<td className="whitespace-nowrap px-4 py-3">
<OTStatusBadge estado={ot.estado} size="sm" />
</td>
<td className="whitespace-nowrap px-4 py-3 text-right text-sm text-gray-900">
{formatCurrency(ot.tarifaTotal)}
<td className="whitespace-nowrap px-4 py-3 text-right text-sm font-medium text-gray-900">
{formatCurrency(ot.tarifaTotal || ot.total)}
</td>
</tr>
))
@ -185,12 +349,22 @@ export function OTList({ onSelect, filters: externalFilters }: OTListProps) {
</div>
{/* Pagination */}
{totalPages > 1 && (
{totalPages > 0 && (
<div className="flex items-center justify-between">
<div className="text-sm text-gray-500">
Mostrando {(page - 1) * limit + 1} - {Math.min(page * limit, total)} de {total}
Mostrando {ordenes.length > 0 ? (page - 1) * limit + 1 : 0} - {Math.min(page * limit, total)} de {total}
</div>
<div className="flex gap-2">
<div className="flex items-center gap-2">
<button
onClick={() => setPage(1)}
disabled={page === 1}
className="rounded-lg border border-gray-300 px-2 py-1 text-sm disabled:opacity-50"
title="Primera pagina"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
</button>
<button
onClick={() => setPage(page - 1)}
disabled={page === 1}
@ -198,6 +372,9 @@ export function OTList({ onSelect, filters: externalFilters }: OTListProps) {
>
Anterior
</button>
<span className="px-3 py-1 text-sm text-gray-700">
Pagina {page} de {totalPages || 1}
</span>
<button
onClick={() => setPage(page + 1)}
disabled={page >= totalPages}
@ -205,6 +382,16 @@ export function OTList({ onSelect, filters: externalFilters }: OTListProps) {
>
Siguiente
</button>
<button
onClick={() => setPage(totalPages)}
disabled={page >= totalPages}
className="rounded-lg border border-gray-300 px-2 py-1 text-sm disabled:opacity-50"
title="Ultima pagina"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
)}

View File

@ -2,3 +2,4 @@ export { OTStatusBadge } from './OTStatusBadge';
export { OTList } from './OTList';
export { OTForm } from './OTForm';
export { OTDetail } from './OTDetail';
export { AsignarUnidadModal } from './AsignarUnidadModal';

View File

@ -0,0 +1,51 @@
import { api } from '@/services/api/axios-instance';
import type {
Tarifa,
CalculoTarifaRequest,
CalculoTarifaResponse,
} from '../types';
const BASE_URL = '/api/v1/tarifas';
export const tarifasApi = {
// Get all tarifas
getAll: async (filters?: { clienteId?: string; activo?: boolean }): Promise<{ data: Tarifa[]; total: number }> => {
const params = new URLSearchParams();
if (filters?.clienteId) params.append('clienteId', filters.clienteId);
if (filters?.activo !== undefined) params.append('activo', String(filters.activo));
const response = await api.get<{ data: Tarifa[]; total: number }>(`${BASE_URL}?${params.toString()}`);
return response.data;
},
// Get tarifa by ID
getById: async (id: string): Promise<{ data: Tarifa }> => {
const response = await api.get<{ data: Tarifa }>(`${BASE_URL}/${id}`);
return response.data;
},
// Calculate tarifa for a route
calcular: async (request: CalculoTarifaRequest): Promise<CalculoTarifaResponse> => {
const response = await api.post<CalculoTarifaResponse>(`${BASE_URL}/calcular`, request);
return response.data;
},
// Search tarifas by route
buscarPorRuta: async (
origenCiudad: string,
origenEstado: string,
destinoCiudad: string,
destinoEstado: string,
clienteId?: string
): Promise<{ data: Tarifa[] }> => {
const params = new URLSearchParams();
params.append('origenCiudad', origenCiudad);
params.append('origenEstado', origenEstado);
params.append('destinoCiudad', destinoCiudad);
params.append('destinoEstado', destinoEstado);
if (clienteId) params.append('clienteId', clienteId);
const response = await api.get<{ data: Tarifa[] }>(`${BASE_URL}/buscar-ruta?${params.toString()}`);
return response.data;
},
};

View File

@ -0,0 +1,5 @@
// Types
export * from './types';
// API
export { tarifasApi } from './api/tarifas.api';

View File

@ -0,0 +1,71 @@
/**
* Tarifa Types
*/
export enum TipoTarifa {
POR_VIAJE = 'POR_VIAJE',
POR_KILOMETRO = 'POR_KILOMETRO',
POR_TONELADA = 'POR_TONELADA',
POR_VOLUMEN = 'POR_VOLUMEN',
POR_HORA = 'POR_HORA',
COMBINADA = 'COMBINADA',
}
export interface Tarifa {
id: string;
tenantId: string;
codigo: string;
nombre: string;
tipo: TipoTarifa;
clienteId?: string;
// Ruta
origenCiudad?: string;
origenEstado?: string;
destinoCiudad?: string;
destinoEstado?: string;
distanciaKm?: number;
// Precios
tarifaBase: number;
precioPorKm?: number;
precioPorTonelada?: number;
precioPorM3?: number;
precioPorHora?: number;
// Recargos
fuelSurchargePercent?: number;
tollsIncluded: boolean;
// Vigencia
fechaInicio?: string;
fechaFin?: string;
activo: boolean;
createdAt: string;
updatedAt: string;
}
export interface CalculoTarifaRequest {
origenCiudad: string;
origenEstado: string;
destinoCiudad: string;
destinoEstado: string;
clienteId?: string;
modalidad?: string;
tipoCarga?: string;
tipoEquipo?: string;
pesoKg?: number;
volumenM3?: number;
}
export interface CalculoTarifaResponse {
tarifaId?: string;
tarifaBase: number;
distanciaKm?: number;
fuelSurcharge: number;
otrosRecargos: number;
subtotal: number;
iva: number;
total: number;
moneda: string;
desglose: {
concepto: string;
monto: number;
}[];
}

View File

@ -1,47 +1,170 @@
import { useState } from 'react';
import { Routes, Route, useNavigate } from 'react-router-dom';
import { OTList, OTForm, OTDetail } from '../features/ordenes-transporte';
import type { OrdenTransporte } from '../features/ordenes-transporte';
import { useQuery } from '@tanstack/react-query';
import { OTList, OTForm, OTDetail, ordenesTransporteApi } from '../features/ordenes-transporte';
import { AsignarUnidadModal } from '../features/ordenes-transporte/components/AsignarUnidadModal';
import type { OrdenTransporte, EstadoOrdenTransporte } from '../features/ordenes-transporte';
type TabKey = 'todas' | 'pendientes' | 'en_transito' | 'completadas';
const tabEstados: Record<TabKey, EstadoOrdenTransporte[] | undefined> = {
todas: undefined,
pendientes: ['BORRADOR', 'PENDIENTE', 'SOLICITADA', 'CONFIRMADA'] as EstadoOrdenTransporte[],
en_transito: ['ASIGNADA', 'EN_PROCESO', 'EN_TRANSITO'] as EstadoOrdenTransporte[],
completadas: ['COMPLETADA', 'ENTREGADA', 'FACTURADA'] as EstadoOrdenTransporte[],
};
function OTListPage() {
const navigate = useNavigate();
const [selectedOT, setSelectedOT] = useState<OrdenTransporte | null>(null);
const [showAsignarModal, setShowAsignarModal] = useState(false);
const [activeTab, setActiveTab] = useState<TabKey>('todas');
// Fetch statistics
const { data: statsData } = useQuery({
queryKey: ['ordenes-transporte-stats'],
queryFn: async () => {
const [todas, pendientes, enTransito] = await Promise.all([
ordenesTransporteApi.getAll({ limit: 1 }),
ordenesTransporteApi.getAll({ estados: tabEstados.pendientes, limit: 1 }),
ordenesTransporteApi.getAll({ estados: tabEstados.en_transito, limit: 1 }),
]);
return {
total: todas.total,
pendientes: pendientes.total,
enTransito: enTransito.total,
};
},
staleTime: 30000,
});
const stats = statsData || { total: 0, pendientes: 0, enTransito: 0 };
const tabs: { key: TabKey; label: string; count?: number }[] = [
{ key: 'todas', label: 'Todas', count: stats.total },
{ key: 'pendientes', label: 'Pendientes', count: stats.pendientes },
{ key: 'en_transito', label: 'En Transito', count: stats.enTransito },
{ key: 'completadas', label: 'Completadas' },
];
const handleSelectOT = (ot: OrdenTransporte) => {
setSelectedOT(ot);
};
const handleAsignarUnidad = () => {
if (selectedOT) {
setShowAsignarModal(true);
}
};
const handleAsignacionSuccess = () => {
setShowAsignarModal(false);
setSelectedOT(null);
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Órdenes de Transporte</h1>
<h1 className="text-2xl font-bold text-gray-900">Ordenes de Transporte</h1>
<button
onClick={() => navigate('/ordenes-transporte/nueva')}
className="rounded-lg bg-primary-600 px-4 py-2 text-white hover:bg-primary-700"
className="flex items-center gap-2 rounded-lg bg-primary-600 px-4 py-2 text-white hover:bg-primary-700"
>
Nueva Orden
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Nueva OT
</button>
</div>
<OTList onSelect={setSelectedOT} />
{/* Stats Cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className="text-sm font-medium text-gray-500">Total Ordenes</div>
<div className="mt-1 text-2xl font-bold text-gray-900">{stats.total}</div>
</div>
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
<div className="text-sm font-medium text-yellow-700">Pendientes</div>
<div className="mt-1 text-2xl font-bold text-yellow-900">{stats.pendientes}</div>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<div className="text-sm font-medium text-blue-700">En Transito</div>
<div className="mt-1 text-2xl font-bold text-blue-900">{stats.enTransito}</div>
</div>
</div>
{/* Tabs */}
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium ${
activeTab === tab.key
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
}`}
>
{tab.label}
{tab.count !== undefined && (
<span
className={`ml-2 rounded-full px-2 py-0.5 text-xs ${
activeTab === tab.key
? 'bg-primary-100 text-primary-600'
: 'bg-gray-100 text-gray-600'
}`}
>
{tab.count}
</span>
)}
</button>
))}
</nav>
</div>
{/* List */}
<OTList
onSelect={handleSelectOT}
filters={{ estados: tabEstados[activeTab] }}
/>
{/* Detail Modal */}
{selectedOT && (
{selectedOT && !showAsignarModal && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-screen items-end justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0">
<div
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
onClick={() => setSelectedOT(null)}
/>
<div className="inline-block transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-3xl sm:p-6 sm:align-middle">
<div className="inline-block transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-4xl sm:p-6 sm:align-middle">
<OTDetail
orden={selectedOT}
onClose={() => setSelectedOT(null)}
onEdit={() => {
const id = selectedOT.id;
setSelectedOT(null);
navigate(`/ordenes-transporte/${selectedOT.id}/editar`);
navigate(`/ordenes-transporte/${id}/editar`);
}}
onAsignarUnidad={
selectedOT.estado === 'CONFIRMADA' ? handleAsignarUnidad : undefined
}
/>
</div>
</div>
</div>
)}
{/* Asignar Unidad Modal */}
{showAsignarModal && selectedOT && (
<AsignarUnidadModal
ordenId={selectedOT.id}
ordenNumero={selectedOT.numero || selectedOT.codigo}
onClose={() => setShowAsignarModal(false)}
onSuccess={handleAsignacionSuccess}
/>
)}
</div>
);
}
@ -51,7 +174,44 @@ function OTNuevaPage() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900">Nueva Orden de Transporte</h1>
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/ordenes-transporte')}
className="rounded-lg border border-gray-300 p-2 hover:bg-gray-50"
>
<svg className="h-5 w-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<h1 className="text-2xl font-bold text-gray-900">Nueva Orden de Transporte</h1>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-6">
<OTForm
onSuccess={() => navigate('/ordenes-transporte')}
onCancel={() => navigate('/ordenes-transporte')}
/>
</div>
</div>
);
}
function OTEditarPage() {
const navigate = useNavigate();
// TODO: Get orden from route params and fetch data
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/ordenes-transporte')}
className="rounded-lg border border-gray-300 p-2 hover:bg-gray-50"
>
<svg className="h-5 w-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<h1 className="text-2xl font-bold text-gray-900">Editar Orden de Transporte</h1>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-6">
<OTForm
onSuccess={() => navigate('/ordenes-transporte')}
@ -67,6 +227,7 @@ export default function OrdenesTransportePage() {
<Routes>
<Route index element={<OTListPage />} />
<Route path="nueva" element={<OTNuevaPage />} />
<Route path=":id/editar" element={<OTEditarPage />} />
</Routes>
);
}