[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:
parent
0bc16b52bf
commit
f14e6112cb
57
src/features/clientes/api/clientes.api.ts
Normal file
57
src/features/clientes/api/clientes.api.ts
Normal 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}`);
|
||||
},
|
||||
};
|
||||
5
src/features/clientes/index.ts
Normal file
5
src/features/clientes/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// Types
|
||||
export * from './types';
|
||||
|
||||
// API
|
||||
export { clientesApi } from './api/clientes.api';
|
||||
82
src/features/clientes/types/index.ts
Normal file
82
src/features/clientes/types/index.ts
Normal 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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -2,3 +2,4 @@ export { OTStatusBadge } from './OTStatusBadge';
|
||||
export { OTList } from './OTList';
|
||||
export { OTForm } from './OTForm';
|
||||
export { OTDetail } from './OTDetail';
|
||||
export { AsignarUnidadModal } from './AsignarUnidadModal';
|
||||
|
||||
51
src/features/tarifas/api/tarifas.api.ts
Normal file
51
src/features/tarifas/api/tarifas.api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
5
src/features/tarifas/index.ts
Normal file
5
src/features/tarifas/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// Types
|
||||
export * from './types';
|
||||
|
||||
// API
|
||||
export { tarifasApi } from './api/tarifas.api';
|
||||
71
src/features/tarifas/types/index.ts
Normal file
71
src/features/tarifas/types/index.ts
Normal 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;
|
||||
}[];
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user