[MAI-003,006,011] feat: Add frontend React components for transport modules
- Add viajes module with ViajesList, ViajeForm, ViajeDetail, ViajeStatusBadge - Add flota module with UnidadesList, OperadoresList, StatusBadge components - Add tracking module with TrackingMap, EventosList, GeocercasList - Add ordenes-transporte module with OTList, OTForm, OTDetail, OTStatusBadge - Add types, API services, and feature index files for all modules - Add main entry files (App.tsx, main.tsx, index.css) - Add page components for routing - Configure Tailwind CSS with primary colors Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
efc8cef4cd
commit
f0a09fea29
17
index.html
Normal file
17
index.html
Normal file
@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ERP Transportistas</title>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
65
src/App.tsx
Normal file
65
src/App.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Suspense, lazy } from 'react';
|
||||
|
||||
// Lazy load pages
|
||||
const ViajesPage = lazy(() => import('./pages/ViajesPage'));
|
||||
const FlotaPage = lazy(() => import('./pages/FlotaPage'));
|
||||
const TrackingPage = lazy(() => import('./pages/TrackingPage'));
|
||||
const OrdenesTransportePage = lazy(() => import('./pages/OrdenesTransportePage'));
|
||||
|
||||
// Loading fallback
|
||||
function PageLoader() {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="text-gray-500">Cargando...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main Layout
|
||||
function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<header className="border-b border-gray-200 bg-white">
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4">
|
||||
<div className="flex items-center gap-8">
|
||||
<h1 className="text-xl font-bold text-primary-600">ERP Transportistas</h1>
|
||||
<nav className="flex gap-4">
|
||||
<a href="/viajes" className="text-gray-600 hover:text-gray-900">Viajes</a>
|
||||
<a href="/flota" className="text-gray-600 hover:text-gray-900">Flota</a>
|
||||
<a href="/tracking" className="text-gray-600 hover:text-gray-900">Tracking</a>
|
||||
<a href="/ordenes-transporte" className="text-gray-600 hover:text-gray-900">Órdenes</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-500">Usuario</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="mx-auto max-w-7xl p-4">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<MainLayout>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/viajes" replace />} />
|
||||
<Route path="/viajes/*" element={<ViajesPage />} />
|
||||
<Route path="/flota/*" element={<FlotaPage />} />
|
||||
<Route path="/tracking/*" element={<TrackingPage />} />
|
||||
<Route path="/ordenes-transporte/*" element={<OrdenesTransportePage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
76
src/features/flota/api/operadores.api.ts
Normal file
76
src/features/flota/api/operadores.api.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { api } from '@/services/api/axios-instance';
|
||||
import type {
|
||||
Operador,
|
||||
OperadorFilters,
|
||||
OperadoresResponse,
|
||||
CreateOperadorDto,
|
||||
UpdateOperadorDto,
|
||||
EstadoOperador,
|
||||
} from '../types';
|
||||
|
||||
const BASE_URL = '/api/v1/operadores';
|
||||
|
||||
export const operadoresApi = {
|
||||
// Get all operadores with filters
|
||||
getAll: async (filters?: OperadorFilters): Promise<OperadoresResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.search) params.append('search', filters.search);
|
||||
if (filters?.estado) params.append('estado', filters.estado);
|
||||
if (filters?.tipoLicencia) params.append('tipoLicencia', filters.tipoLicencia);
|
||||
if (filters?.sucursalId) params.append('sucursalId', filters.sucursalId);
|
||||
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<OperadoresResponse>(`${BASE_URL}?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get operador by ID
|
||||
getById: async (id: string): Promise<{ data: Operador }> => {
|
||||
const response = await api.get<{ data: Operador }>(`${BASE_URL}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create operador
|
||||
create: async (data: CreateOperadorDto): Promise<{ data: Operador }> => {
|
||||
const response = await api.post<{ data: Operador }>(BASE_URL, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update operador
|
||||
update: async (id: string, data: UpdateOperadorDto): Promise<{ data: Operador }> => {
|
||||
const response = await api.patch<{ data: Operador }>(`${BASE_URL}/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete operador
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`${BASE_URL}/${id}`);
|
||||
},
|
||||
|
||||
// Get operadores disponibles
|
||||
getDisponibles: async (): Promise<{ data: Operador[] }> => {
|
||||
const response = await api.get<{ data: Operador[] }>(`${BASE_URL}/disponibles`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get operadores con licencia por vencer
|
||||
getLicenciaPorVencer: async (dias?: number): Promise<{ data: Operador[] }> => {
|
||||
const params = dias ? `?dias=${dias}` : '';
|
||||
const response = await api.get<{ data: Operador[] }>(`${BASE_URL}/licencia-por-vencer${params}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get operadores con licencia vencida
|
||||
getLicenciaVencida: async (): Promise<{ data: Operador[] }> => {
|
||||
const response = await api.get<{ data: Operador[] }>(`${BASE_URL}/licencia-vencida`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Cambiar estado
|
||||
cambiarEstado: async (id: string, estado: EstadoOperador): Promise<{ data: Operador }> => {
|
||||
const response = await api.post<{ data: Operador }>(`${BASE_URL}/${id}/estado`, { estado });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
84
src/features/flota/api/unidades.api.ts
Normal file
84
src/features/flota/api/unidades.api.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { api } from '@/services/api/axios-instance';
|
||||
import type {
|
||||
Unidad,
|
||||
UnidadFilters,
|
||||
UnidadesResponse,
|
||||
CreateUnidadDto,
|
||||
UpdateUnidadDto,
|
||||
EstadoUnidad,
|
||||
} from '../types';
|
||||
|
||||
const BASE_URL = '/api/v1/products';
|
||||
|
||||
export const unidadesApi = {
|
||||
// Get all unidades with filters
|
||||
getAll: async (filters?: UnidadFilters): Promise<UnidadesResponse> => {
|
||||
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?.sucursalId) params.append('sucursalId', filters.sucursalId);
|
||||
if (filters?.esPropia !== undefined) params.append('esPropia', String(filters.esPropia));
|
||||
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<UnidadesResponse>(`${BASE_URL}?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get unidad by ID
|
||||
getById: async (id: string): Promise<{ data: Unidad }> => {
|
||||
const response = await api.get<{ data: Unidad }>(`${BASE_URL}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create unidad
|
||||
create: async (data: CreateUnidadDto): Promise<{ data: Unidad }> => {
|
||||
const response = await api.post<{ data: Unidad }>(BASE_URL, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update unidad
|
||||
update: async (id: string, data: UpdateUnidadDto): Promise<{ data: Unidad }> => {
|
||||
const response = await api.patch<{ data: Unidad }>(`${BASE_URL}/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete unidad
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`${BASE_URL}/${id}`);
|
||||
},
|
||||
|
||||
// Get unidades disponibles
|
||||
getDisponibles: async (): Promise<{ data: Unidad[] }> => {
|
||||
const response = await api.get<{ data: Unidad[] }>(`${BASE_URL}/disponibles`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get unidades con seguro por vencer
|
||||
getSeguroPorVencer: async (dias?: number): Promise<{ data: Unidad[] }> => {
|
||||
const params = dias ? `?dias=${dias}` : '';
|
||||
const response = await api.get<{ data: Unidad[] }>(`${BASE_URL}/seguro-por-vencer${params}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get unidades con verificacion por vencer
|
||||
getVerificacionPorVencer: async (dias?: number): Promise<{ data: Unidad[] }> => {
|
||||
const params = dias ? `?dias=${dias}` : '';
|
||||
const response = await api.get<{ data: Unidad[] }>(`${BASE_URL}/verificacion-por-vencer${params}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Cambiar estado
|
||||
cambiarEstado: async (id: string, estado: EstadoUnidad): Promise<{ data: Unidad }> => {
|
||||
const response = await api.post<{ data: Unidad }>(`${BASE_URL}/${id}/estado`, { estado });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Actualizar odometro
|
||||
actualizarOdometro: async (id: string, odometro: number): Promise<{ data: Unidad }> => {
|
||||
const response = await api.post<{ data: Unidad }>(`${BASE_URL}/${id}/odometro`, { odometro });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
36
src/features/flota/components/OperadorStatusBadge.tsx
Normal file
36
src/features/flota/components/OperadorStatusBadge.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { EstadoOperador } from '../types';
|
||||
|
||||
interface OperadorStatusBadgeProps {
|
||||
estado: EstadoOperador;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const estadoConfig: Record<EstadoOperador, { label: string; color: string }> = {
|
||||
[EstadoOperador.ACTIVO]: { label: 'Activo', color: 'bg-green-100 text-green-800' },
|
||||
[EstadoOperador.DISPONIBLE]: { label: 'Disponible', color: 'bg-emerald-100 text-emerald-800' },
|
||||
[EstadoOperador.EN_VIAJE]: { label: 'En Viaje', color: 'bg-blue-100 text-blue-800' },
|
||||
[EstadoOperador.EN_RUTA]: { label: 'En Ruta', color: 'bg-yellow-100 text-yellow-800' },
|
||||
[EstadoOperador.DESCANSO]: { label: 'Descanso', color: 'bg-purple-100 text-purple-800' },
|
||||
[EstadoOperador.VACACIONES]: { label: 'Vacaciones', color: 'bg-indigo-100 text-indigo-800' },
|
||||
[EstadoOperador.INCAPACIDAD]: { label: 'Incapacidad', color: 'bg-orange-100 text-orange-800' },
|
||||
[EstadoOperador.SUSPENDIDO]: { label: 'Suspendido', color: 'bg-red-100 text-red-800' },
|
||||
[EstadoOperador.BAJA]: { label: 'Baja', color: 'bg-gray-100 text-gray-800' },
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-2 py-0.5 text-xs',
|
||||
md: 'px-2.5 py-1 text-sm',
|
||||
lg: 'px-3 py-1.5 text-base',
|
||||
};
|
||||
|
||||
export function OperadorStatusBadge({ estado, size = 'md' }: OperadorStatusBadgeProps) {
|
||||
const config = estadoConfig[estado] || { label: estado, color: 'bg-gray-100 text-gray-800' };
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full font-medium ${config.color} ${sizeClasses[size]}`}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
192
src/features/flota/components/OperadoresList.tsx
Normal file
192
src/features/flota/components/OperadoresList.tsx
Normal file
@ -0,0 +1,192 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { operadoresApi } from '../api/operadores.api';
|
||||
import { OperadorStatusBadge } from './OperadorStatusBadge';
|
||||
import type { Operador, OperadorFilters, EstadoOperador, TipoLicencia } from '../types';
|
||||
|
||||
interface OperadoresListProps {
|
||||
onSelect?: (operador: Operador) => void;
|
||||
filters?: OperadorFilters;
|
||||
}
|
||||
|
||||
export function OperadoresList({ onSelect, filters: externalFilters }: OperadoresListProps) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [estadoFilter, setEstadoFilter] = useState<EstadoOperador | ''>('');
|
||||
const [licenciaFilter, setLicenciaFilter] = useState<TipoLicencia | ''>('');
|
||||
const limit = 10;
|
||||
|
||||
const filters: OperadorFilters = {
|
||||
...externalFilters,
|
||||
estado: estadoFilter || undefined,
|
||||
tipoLicencia: licenciaFilter || undefined,
|
||||
limit,
|
||||
offset: (page - 1) * limit,
|
||||
};
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['operadores', filters],
|
||||
queryFn: () => operadoresApi.getAll(filters),
|
||||
});
|
||||
|
||||
const operadores = data?.data || [];
|
||||
const total = data?.total || 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return '-';
|
||||
return new Date(dateString).toLocaleDateString('es-MX');
|
||||
};
|
||||
|
||||
const isLicenciaVencida = (vigencia?: string) => {
|
||||
if (!vigencia) return false;
|
||||
return new Date(vigencia) < new Date();
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-lg bg-red-50 p-4 text-red-700">
|
||||
Error al cargar operadores: {(error as Error).message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-4">
|
||||
<select
|
||||
value={estadoFilter}
|
||||
onChange={(e) => setEstadoFilter(e.target.value as EstadoOperador | '')}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Todos los estados</option>
|
||||
<option value="ACTIVO">Activo</option>
|
||||
<option value="DISPONIBLE">Disponible</option>
|
||||
<option value="EN_VIAJE">En Viaje</option>
|
||||
<option value="EN_RUTA">En Ruta</option>
|
||||
<option value="DESCANSO">Descanso</option>
|
||||
<option value="VACACIONES">Vacaciones</option>
|
||||
<option value="INCAPACIDAD">Incapacidad</option>
|
||||
<option value="SUSPENDIDO">Suspendido</option>
|
||||
<option value="BAJA">Baja</option>
|
||||
</select>
|
||||
<select
|
||||
value={licenciaFilter}
|
||||
onChange={(e) => setLicenciaFilter(e.target.value as TipoLicencia | '')}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Todas las licencias</option>
|
||||
<option value="A">Tipo A</option>
|
||||
<option value="B">Tipo B</option>
|
||||
<option value="C">Tipo C</option>
|
||||
<option value="D">Tipo D</option>
|
||||
<option value="E">Tipo E</option>
|
||||
<option value="F">Tipo F (Federal)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Operador
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Licencia
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Vigencia
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Estado
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Viajes
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
|
||||
Cargando operadores...
|
||||
</td>
|
||||
</tr>
|
||||
) : operadores.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
|
||||
No hay operadores para mostrar
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
operadores.map((operador) => (
|
||||
<tr
|
||||
key={operador.id}
|
||||
onClick={() => onSelect?.(operador)}
|
||||
className="cursor-pointer hover:bg-gray-50"
|
||||
>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<div className="font-medium text-gray-900">
|
||||
{operador.nombre} {operador.apellidoPaterno} {operador.apellidoMaterno}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">{operador.numeroEmpleado}</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
|
||||
{operador.tipoLicencia ? `Tipo ${operador.tipoLicencia}` : '-'}
|
||||
{operador.numeroLicencia && (
|
||||
<div className="text-xs">{operador.numeroLicencia}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<span
|
||||
className={
|
||||
isLicenciaVencida(operador.licenciaVigencia)
|
||||
? 'text-red-600'
|
||||
: 'text-gray-500'
|
||||
}
|
||||
>
|
||||
{formatDate(operador.licenciaVigencia)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<OperadorStatusBadge estado={operador.estado} size="sm" />
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-right text-sm text-gray-500">
|
||||
{operador.totalViajes.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<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}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||
>
|
||||
Siguiente
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
src/features/flota/components/UnidadStatusBadge.tsx
Normal file
33
src/features/flota/components/UnidadStatusBadge.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { EstadoUnidad } from '../types';
|
||||
|
||||
interface UnidadStatusBadgeProps {
|
||||
estado: EstadoUnidad;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const estadoConfig: Record<EstadoUnidad, { label: string; color: string }> = {
|
||||
[EstadoUnidad.DISPONIBLE]: { label: 'Disponible', color: 'bg-green-100 text-green-800' },
|
||||
[EstadoUnidad.EN_VIAJE]: { label: 'En Viaje', color: 'bg-blue-100 text-blue-800' },
|
||||
[EstadoUnidad.EN_RUTA]: { label: 'En Ruta', color: 'bg-yellow-100 text-yellow-800' },
|
||||
[EstadoUnidad.EN_TALLER]: { label: 'En Taller', color: 'bg-orange-100 text-orange-800' },
|
||||
[EstadoUnidad.BLOQUEADA]: { label: 'Bloqueada', color: 'bg-red-100 text-red-800' },
|
||||
[EstadoUnidad.BAJA]: { label: 'Baja', color: 'bg-gray-100 text-gray-800' },
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-2 py-0.5 text-xs',
|
||||
md: 'px-2.5 py-1 text-sm',
|
||||
lg: 'px-3 py-1.5 text-base',
|
||||
};
|
||||
|
||||
export function UnidadStatusBadge({ estado, size = 'md' }: UnidadStatusBadgeProps) {
|
||||
const config = estadoConfig[estado] || { label: estado, color: 'bg-gray-100 text-gray-800' };
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full font-medium ${config.color} ${sizeClasses[size]}`}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
171
src/features/flota/components/UnidadesList.tsx
Normal file
171
src/features/flota/components/UnidadesList.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { unidadesApi } from '../api/unidades.api';
|
||||
import { UnidadStatusBadge } from './UnidadStatusBadge';
|
||||
import type { Unidad, UnidadFilters, EstadoUnidad, TipoUnidad } from '../types';
|
||||
|
||||
interface UnidadesListProps {
|
||||
onSelect?: (unidad: Unidad) => void;
|
||||
filters?: UnidadFilters;
|
||||
}
|
||||
|
||||
export function UnidadesList({ onSelect, filters: externalFilters }: UnidadesListProps) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [estadoFilter, setEstadoFilter] = useState<EstadoUnidad | ''>('');
|
||||
const [tipoFilter, setTipoFilter] = useState<TipoUnidad | ''>('');
|
||||
const limit = 10;
|
||||
|
||||
const filters: UnidadFilters = {
|
||||
...externalFilters,
|
||||
estado: estadoFilter || undefined,
|
||||
tipo: tipoFilter || undefined,
|
||||
limit,
|
||||
offset: (page - 1) * limit,
|
||||
};
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['unidades', filters],
|
||||
queryFn: () => unidadesApi.getAll(filters),
|
||||
});
|
||||
|
||||
const unidades = data?.data || [];
|
||||
const total = data?.total || 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-lg bg-red-50 p-4 text-red-700">
|
||||
Error al cargar unidades: {(error as Error).message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-4">
|
||||
<select
|
||||
value={estadoFilter}
|
||||
onChange={(e) => setEstadoFilter(e.target.value as EstadoUnidad | '')}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Todos los estados</option>
|
||||
<option value="DISPONIBLE">Disponible</option>
|
||||
<option value="EN_VIAJE">En Viaje</option>
|
||||
<option value="EN_RUTA">En Ruta</option>
|
||||
<option value="EN_TALLER">En Taller</option>
|
||||
<option value="BLOQUEADA">Bloqueada</option>
|
||||
<option value="BAJA">Baja</option>
|
||||
</select>
|
||||
<select
|
||||
value={tipoFilter}
|
||||
onChange={(e) => setTipoFilter(e.target.value as TipoUnidad | '')}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Todos los tipos</option>
|
||||
<option value="TRACTORA">Tractora</option>
|
||||
<option value="REMOLQUE">Remolque</option>
|
||||
<option value="CAJA_SECA">Caja Seca</option>
|
||||
<option value="CAJA_REFRIGERADA">Caja Refrigerada</option>
|
||||
<option value="PLATAFORMA">Plataforma</option>
|
||||
<option value="TANQUE">Tanque</option>
|
||||
<option value="TORTON">Torton</option>
|
||||
<option value="RABON">Rabon</option>
|
||||
<option value="CAMIONETA">Camioneta</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Unidad
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Tipo
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Placas
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Estado
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Odometro
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
|
||||
Cargando unidades...
|
||||
</td>
|
||||
</tr>
|
||||
) : unidades.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
|
||||
No hay unidades para mostrar
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
unidades.map((unidad) => (
|
||||
<tr
|
||||
key={unidad.id}
|
||||
onClick={() => onSelect?.(unidad)}
|
||||
className="cursor-pointer hover:bg-gray-50"
|
||||
>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<div className="font-medium text-gray-900">{unidad.numeroEconomico}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{unidad.marca} {unidad.modelo} {unidad.anio}
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
|
||||
{unidad.tipo.replace(/_/g, ' ')}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
|
||||
{unidad.placa || unidad.placas || '-'}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<UnidadStatusBadge estado={unidad.estado} size="sm" />
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-right text-sm text-gray-500">
|
||||
{unidad.odometroActual.toLocaleString()} km
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<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}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||
>
|
||||
Siguiente
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
src/features/flota/components/index.ts
Normal file
4
src/features/flota/components/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { UnidadStatusBadge } from './UnidadStatusBadge';
|
||||
export { OperadorStatusBadge } from './OperadorStatusBadge';
|
||||
export { UnidadesList } from './UnidadesList';
|
||||
export { OperadoresList } from './OperadoresList';
|
||||
12
src/features/flota/index.ts
Normal file
12
src/features/flota/index.ts
Normal file
@ -0,0 +1,12 @@
|
||||
// Types
|
||||
export * from './types';
|
||||
|
||||
// API
|
||||
export { unidadesApi } from './api/unidades.api';
|
||||
export { operadoresApi } from './api/operadores.api';
|
||||
|
||||
// Components
|
||||
export { UnidadStatusBadge } from './components/UnidadStatusBadge';
|
||||
export { OperadorStatusBadge } from './components/OperadorStatusBadge';
|
||||
export { UnidadesList } from './components/UnidadesList';
|
||||
export { OperadoresList } from './components/OperadoresList';
|
||||
216
src/features/flota/types/index.ts
Normal file
216
src/features/flota/types/index.ts
Normal file
@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Flota (Fleet) Types - Unidades y Operadores
|
||||
*/
|
||||
|
||||
// ==================== Unidades ====================
|
||||
|
||||
export enum TipoUnidad {
|
||||
TRACTORA = 'TRACTORA',
|
||||
REMOLQUE = 'REMOLQUE',
|
||||
CAJA_SECA = 'CAJA_SECA',
|
||||
CAJA_REFRIGERADA = 'CAJA_REFRIGERADA',
|
||||
PLATAFORMA = 'PLATAFORMA',
|
||||
TANQUE = 'TANQUE',
|
||||
PORTACONTENEDOR = 'PORTACONTENEDOR',
|
||||
TORTON = 'TORTON',
|
||||
RABON = 'RABON',
|
||||
CAMIONETA = 'CAMIONETA',
|
||||
}
|
||||
|
||||
export enum EstadoUnidad {
|
||||
DISPONIBLE = 'DISPONIBLE',
|
||||
EN_VIAJE = 'EN_VIAJE',
|
||||
EN_RUTA = 'EN_RUTA',
|
||||
EN_TALLER = 'EN_TALLER',
|
||||
BLOQUEADA = 'BLOQUEADA',
|
||||
BAJA = 'BAJA',
|
||||
}
|
||||
|
||||
export interface Unidad {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
sucursalId?: string;
|
||||
numeroEconomico: string;
|
||||
tipo: TipoUnidad;
|
||||
marca?: string;
|
||||
modelo?: string;
|
||||
anio?: number;
|
||||
color?: string;
|
||||
numeroSerie?: string;
|
||||
numeroMotor?: string;
|
||||
placa?: string;
|
||||
placas?: string;
|
||||
placaEstado?: string;
|
||||
permisoSct?: string;
|
||||
tipoPermisoSct?: string;
|
||||
configuracionVehicular?: string;
|
||||
capacidadPesoKg?: number;
|
||||
capacidadVolumenM3?: number;
|
||||
capacidadPallets?: number;
|
||||
tipoCombustible?: string;
|
||||
rendimientoKmLitro?: number;
|
||||
capacidadTanqueLitros?: number;
|
||||
odometroActual: number;
|
||||
odometroUltimoServicio?: number;
|
||||
tieneGps: boolean;
|
||||
gpsProveedor?: string;
|
||||
gpsImei?: string;
|
||||
estado: EstadoUnidad;
|
||||
ubicacionActualLat?: number;
|
||||
ubicacionActualLng?: number;
|
||||
ultimaActualizacionUbicacion?: string;
|
||||
esPropia: boolean;
|
||||
propietarioId?: string;
|
||||
costoAdquisicion?: number;
|
||||
fechaAdquisicion?: string;
|
||||
valorActual?: number;
|
||||
fechaVerificacionProxima?: string;
|
||||
fechaPolizaVencimiento?: string;
|
||||
fechaPermisoVencimiento?: string;
|
||||
activo: boolean;
|
||||
fechaBaja?: string;
|
||||
motivoBaja?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UnidadFilters {
|
||||
search?: string;
|
||||
tipo?: TipoUnidad;
|
||||
estado?: EstadoUnidad;
|
||||
sucursalId?: string;
|
||||
esPropia?: boolean;
|
||||
activo?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface UnidadesResponse {
|
||||
data: Unidad[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface CreateUnidadDto {
|
||||
numeroEconomico: string;
|
||||
tipo: TipoUnidad;
|
||||
marca?: string;
|
||||
modelo?: string;
|
||||
anio?: number;
|
||||
placa?: string;
|
||||
sucursalId?: string;
|
||||
capacidadPesoKg?: number;
|
||||
capacidadVolumenM3?: number;
|
||||
tieneGps?: boolean;
|
||||
esPropia?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateUnidadDto extends Partial<CreateUnidadDto> {
|
||||
estado?: EstadoUnidad;
|
||||
odometroActual?: number;
|
||||
}
|
||||
|
||||
// ==================== Operadores ====================
|
||||
|
||||
export enum TipoLicencia {
|
||||
A = 'A',
|
||||
B = 'B',
|
||||
C = 'C',
|
||||
D = 'D',
|
||||
E = 'E',
|
||||
F = 'F',
|
||||
}
|
||||
|
||||
export enum EstadoOperador {
|
||||
ACTIVO = 'ACTIVO',
|
||||
DISPONIBLE = 'DISPONIBLE',
|
||||
EN_VIAJE = 'EN_VIAJE',
|
||||
EN_RUTA = 'EN_RUTA',
|
||||
DESCANSO = 'DESCANSO',
|
||||
VACACIONES = 'VACACIONES',
|
||||
INCAPACIDAD = 'INCAPACIDAD',
|
||||
SUSPENDIDO = 'SUSPENDIDO',
|
||||
BAJA = 'BAJA',
|
||||
}
|
||||
|
||||
export interface Operador {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
sucursalId?: string;
|
||||
numeroEmpleado: string;
|
||||
nombre: string;
|
||||
apellidoPaterno: string;
|
||||
apellidoMaterno?: string;
|
||||
curp?: string;
|
||||
rfc?: string;
|
||||
nss?: string;
|
||||
telefono?: string;
|
||||
telefonoEmergencia?: string;
|
||||
email?: string;
|
||||
direccion?: string;
|
||||
codigoPostal?: string;
|
||||
ciudad?: string;
|
||||
estadoResidencia?: string;
|
||||
fechaNacimiento?: string;
|
||||
lugarNacimiento?: string;
|
||||
nacionalidad: string;
|
||||
tipoLicencia?: TipoLicencia;
|
||||
numeroLicencia?: string;
|
||||
licenciaVigencia?: string;
|
||||
licenciaEstadoExpedicion?: string;
|
||||
certificadoFisicoVigencia?: string;
|
||||
antidopingVigencia?: string;
|
||||
capacitacionMaterialesPeligrosos: boolean;
|
||||
capacitacionMpVigencia?: string;
|
||||
estado: EstadoOperador;
|
||||
unidadAsignadaId?: string;
|
||||
calificacion: number;
|
||||
totalViajes: number;
|
||||
totalKm: number;
|
||||
incidentes: number;
|
||||
banco?: string;
|
||||
cuentaBancaria?: string;
|
||||
clabe?: string;
|
||||
salarioBase?: number;
|
||||
tipoPago?: string;
|
||||
fechaIngreso?: string;
|
||||
fechaBaja?: string;
|
||||
motivoBaja?: string;
|
||||
activo: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface OperadorFilters {
|
||||
search?: string;
|
||||
estado?: EstadoOperador;
|
||||
tipoLicencia?: TipoLicencia;
|
||||
sucursalId?: string;
|
||||
activo?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface OperadoresResponse {
|
||||
data: Operador[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface CreateOperadorDto {
|
||||
numeroEmpleado: string;
|
||||
nombre: string;
|
||||
apellidoPaterno: string;
|
||||
apellidoMaterno?: string;
|
||||
curp?: string;
|
||||
rfc?: string;
|
||||
telefono?: string;
|
||||
email?: string;
|
||||
tipoLicencia?: TipoLicencia;
|
||||
numeroLicencia?: string;
|
||||
licenciaVigencia?: string;
|
||||
sucursalId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateOperadorDto extends Partial<CreateOperadorDto> {
|
||||
estado?: EstadoOperador;
|
||||
unidadAsignadaId?: string;
|
||||
}
|
||||
@ -0,0 +1,97 @@
|
||||
import { api } from '@/services/api/axios-instance';
|
||||
import type {
|
||||
OrdenTransporte,
|
||||
OrdenTransporteFilters,
|
||||
OrdenesTransporteResponse,
|
||||
CreateOrdenTransporteDto,
|
||||
UpdateOrdenTransporteDto,
|
||||
EstadoOrdenTransporte,
|
||||
} from '../types';
|
||||
|
||||
const BASE_URL = '/api/v1/ordenes-transporte';
|
||||
|
||||
export const ordenesTransporteApi = {
|
||||
// Get all ordenes with filters
|
||||
getAll: async (filters?: OrdenTransporteFilters): Promise<OrdenesTransporteResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.search) params.append('search', filters.search);
|
||||
if (filters?.estado) params.append('estado', filters.estado);
|
||||
if (filters?.estados) params.append('estados', filters.estados.join(','));
|
||||
if (filters?.clienteId) params.append('clienteId', filters.clienteId);
|
||||
if (filters?.modalidad) params.append('modalidad', filters.modalidad);
|
||||
if (filters?.fechaDesde) params.append('fechaDesde', filters.fechaDesde);
|
||||
if (filters?.fechaHasta) params.append('fechaHasta', filters.fechaHasta);
|
||||
if (filters?.limit) params.append('limit', String(filters.limit));
|
||||
if (filters?.offset) params.append('offset', String(filters.offset));
|
||||
|
||||
const response = await api.get<OrdenesTransporteResponse>(`${BASE_URL}?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get orden by ID
|
||||
getById: async (id: string): Promise<{ data: OrdenTransporte }> => {
|
||||
const response = await api.get<{ data: OrdenTransporte }>(`${BASE_URL}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get orden by numero
|
||||
getByNumero: async (numeroOt: string): Promise<{ data: OrdenTransporte }> => {
|
||||
const response = await api.get<{ data: OrdenTransporte }>(`${BASE_URL}/numero/${numeroOt}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create orden
|
||||
create: async (data: CreateOrdenTransporteDto): Promise<{ data: OrdenTransporte }> => {
|
||||
const response = await api.post<{ data: OrdenTransporte }>(BASE_URL, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update orden
|
||||
update: async (id: string, data: UpdateOrdenTransporteDto): Promise<{ data: OrdenTransporte }> => {
|
||||
const response = await api.patch<{ data: OrdenTransporte }>(`${BASE_URL}/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Cambiar estado
|
||||
cambiarEstado: async (id: string, estado: EstadoOrdenTransporte): Promise<{ data: OrdenTransporte }> => {
|
||||
const response = await api.post<{ data: OrdenTransporte }>(`${BASE_URL}/${id}/estado`, { estado });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Confirmar orden
|
||||
confirmar: async (id: string): Promise<{ data: OrdenTransporte }> => {
|
||||
const response = await api.post<{ data: OrdenTransporte }>(`${BASE_URL}/${id}/confirmar`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Asignar a viaje
|
||||
asignar: async (id: string, viajeId: string): Promise<{ data: OrdenTransporte }> => {
|
||||
const response = await api.post<{ data: OrdenTransporte }>(`${BASE_URL}/${id}/asignar`, { viajeId });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Cancelar orden
|
||||
cancelar: async (id: string, motivo: string): Promise<{ data: OrdenTransporte }> => {
|
||||
const response = await api.post<{ data: OrdenTransporte }>(`${BASE_URL}/${id}/cancelar`, { motivo });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get ordenes pendientes
|
||||
getPendientes: async (): Promise<{ data: OrdenTransporte[] }> => {
|
||||
const response = await api.get<{ data: OrdenTransporte[] }>(`${BASE_URL}/pendientes`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get ordenes para programacion
|
||||
getParaProgramacion: async (fecha: string): Promise<{ data: OrdenTransporte[] }> => {
|
||||
const response = await api.get<{ data: OrdenTransporte[] }>(`${BASE_URL}/programacion?fecha=${fecha}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get ordenes por cliente
|
||||
getByCliente: async (clienteId: string, limit?: number): Promise<{ data: OrdenTransporte[] }> => {
|
||||
const params = limit ? `?limit=${limit}` : '';
|
||||
const response = await api.get<{ data: OrdenTransporte[] }>(`${BASE_URL}/cliente/${clienteId}${params}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
255
src/features/ordenes-transporte/components/OTDetail.tsx
Normal file
255
src/features/ordenes-transporte/components/OTDetail.tsx
Normal file
@ -0,0 +1,255 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ordenesTransporteApi } from '../api/ordenes-transporte.api';
|
||||
import { OTStatusBadge } from './OTStatusBadge';
|
||||
import type { OrdenTransporte, EstadoOrdenTransporte } from '../types';
|
||||
|
||||
interface OTDetailProps {
|
||||
orden: OrdenTransporte;
|
||||
onClose?: () => void;
|
||||
onEdit?: () => void;
|
||||
}
|
||||
|
||||
export function OTDetail({ orden, onClose, onEdit }: OTDetailProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const confirmarMutation = useMutation({
|
||||
mutationFn: () => ordenesTransporteApi.confirmar(orden.id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['ordenes-transporte'] });
|
||||
},
|
||||
});
|
||||
|
||||
const cancelarMutation = useMutation({
|
||||
mutationFn: () => ordenesTransporteApi.cancelar(orden.id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['ordenes-transporte'] });
|
||||
},
|
||||
});
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return '-';
|
||||
return new Date(dateString).toLocaleDateString('es-MX', {
|
||||
dateStyle: 'medium',
|
||||
});
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString?: string) => {
|
||||
if (!dateString) return '-';
|
||||
return new Date(dateString).toLocaleString('es-MX', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
});
|
||||
};
|
||||
|
||||
const formatCurrency = (amount?: number) => {
|
||||
if (amount === undefined) return '-';
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: orden.moneda || 'MXN',
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const canConfirm = orden.estado === 'PENDIENTE' || orden.estado === 'BORRADOR';
|
||||
const canCancel = ['BORRADOR', 'PENDIENTE', 'CONFIRMADA', 'PROGRAMADA'].includes(orden.estado);
|
||||
|
||||
const handleConfirmar = async () => {
|
||||
if (window.confirm('¿Confirmar esta orden de transporte?')) {
|
||||
await confirmarMutation.mutateAsync();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelar = async () => {
|
||||
if (window.confirm('¿Cancelar esta orden de transporte?')) {
|
||||
await cancelarMutation.mutateAsync();
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
<div className="text-gray-500">{orden.origenPais}</div>
|
||||
</div>
|
||||
</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}
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Notas */}
|
||||
{orden.notas && (
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
455
src/features/ordenes-transporte/components/OTForm.tsx
Normal file
455
src/features/ordenes-transporte/components/OTForm.tsx
Normal file
@ -0,0 +1,455 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ordenesTransporteApi } from '../api/ordenes-transporte.api';
|
||||
import type { OrdenTransporte, CreateOTDto, UpdateOTDto } from '../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(),
|
||||
|
||||
// Origen
|
||||
origenDireccion: z.string().min(1, 'Dirección 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(),
|
||||
|
||||
// Destino
|
||||
destinoDireccion: z.string().min(1, 'Dirección 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(),
|
||||
|
||||
// Fechas
|
||||
fechaRecoleccion: z.string().min(1, 'Fecha de recolección 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(),
|
||||
descripcionMercancia: z.string().optional(),
|
||||
valorMercancia: z.number().positive('Valor debe ser positivo').optional(),
|
||||
|
||||
// Tarifa
|
||||
tarifaBase: z.number().positive('Tarifa debe ser positiva').optional(),
|
||||
tarifaTotal: z.number().positive('Tarifa debe ser positiva').optional(),
|
||||
moneda: z.string().default('MXN'),
|
||||
|
||||
notas: z.string().optional(),
|
||||
});
|
||||
|
||||
type OTFormData = z.infer<typeof otSchema>;
|
||||
|
||||
interface OTFormProps {
|
||||
initialData?: OrdenTransporte;
|
||||
onSuccess?: (ot: OrdenTransporte) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export function OTForm({ initialData, onSuccess, onCancel }: OTFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEditing = !!initialData;
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<OTFormData>({
|
||||
resolver: zodResolver(otSchema),
|
||||
defaultValues: initialData
|
||||
? {
|
||||
clienteId: initialData.clienteId,
|
||||
referenciaCliente: initialData.referenciaCliente || '',
|
||||
modalidad: initialData.modalidad,
|
||||
tipoCarga: initialData.tipoCarga,
|
||||
tipoEquipo: initialData.tipoEquipo,
|
||||
origenDireccion: initialData.origenDireccion,
|
||||
origenCiudad: initialData.origenCiudad,
|
||||
origenEstado: initialData.origenEstado,
|
||||
origenCP: initialData.origenCP || '',
|
||||
origenPais: initialData.origenPais || 'MX',
|
||||
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,
|
||||
pesoNeto: initialData.pesoNeto,
|
||||
volumen: initialData.volumen,
|
||||
cantidadBultos: initialData.cantidadBultos,
|
||||
descripcionMercancia: initialData.descripcionMercancia || '',
|
||||
valorMercancia: initialData.valorMercancia,
|
||||
tarifaBase: initialData.tarifaBase,
|
||||
tarifaTotal: initialData.tarifaTotal,
|
||||
moneda: initialData.moneda || 'MXN',
|
||||
notas: initialData.notas || '',
|
||||
}
|
||||
: {
|
||||
modalidad: 'FTL',
|
||||
tipoCarga: 'GENERAL',
|
||||
origenPais: 'MX',
|
||||
destinoPais: 'MX',
|
||||
moneda: 'MXN',
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateOTDto) => ordenesTransporteApi.create(data),
|
||||
onSuccess: (response) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['ordenes-transporte'] });
|
||||
onSuccess?.(response.data);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: UpdateOTDto) => ordenesTransporteApi.update(initialData!.id, data),
|
||||
onSuccess: (response) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['ordenes-transporte'] });
|
||||
onSuccess?.(response.data);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: OTFormData) => {
|
||||
if (isEditing) {
|
||||
await updateMutation.mutateAsync(data as UpdateOTDto);
|
||||
} else {
|
||||
await createMutation.mutateAsync(data as CreateOTDto);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
<label className="block text-sm font-medium text-gray-700">Cliente *</label>
|
||||
<input
|
||||
type="text"
|
||||
{...register('clienteId')}
|
||||
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"
|
||||
/>
|
||||
{errors.clienteId && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.clienteId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Referencia Cliente</label>
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modalidad, Tipo de Carga, Tipo de Equipo */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Modalidad *</label>
|
||||
<select
|
||||
{...register('modalidad')}
|
||||
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="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>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Tipo de Carga *</label>
|
||||
<select
|
||||
{...register('tipoCarga')}
|
||||
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="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>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Tipo de Equipo</label>
|
||||
<select
|
||||
{...register('tipoEquipo')}
|
||||
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>
|
||||
<option value="CAJA_SECA">Caja Seca</option>
|
||||
<option value="REFRIGERADO">Refrigerado</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>
|
||||
</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>
|
||||
<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>
|
||||
<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"
|
||||
/>
|
||||
{errors.origenDireccion && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.origenDireccion.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Ciudad *</label>
|
||||
<input
|
||||
type="text"
|
||||
{...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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Estado *</label>
|
||||
<input
|
||||
type="text"
|
||||
{...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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Código Postal</label>
|
||||
<input
|
||||
type="text"
|
||||
{...register('origenCP')}
|
||||
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>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
<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"
|
||||
/>
|
||||
{errors.destinoDireccion && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.destinoDireccion.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Ciudad *</label>
|
||||
<input
|
||||
type="text"
|
||||
{...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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Estado *</label>
|
||||
<input
|
||||
type="text"
|
||||
{...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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Código Postal</label>
|
||||
<input
|
||||
type="text"
|
||||
{...register('destinoCP')}
|
||||
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>
|
||||
<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>
|
||||
</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>
|
||||
<input
|
||||
type="date"
|
||||
{...register('fechaRecoleccion')}
|
||||
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
{errors.fechaRecoleccion && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.fechaRecoleccion.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Fecha Entrega Estimada</label>
|
||||
<input
|
||||
type="date"
|
||||
{...register('fechaEntregaEstimada')}
|
||||
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>
|
||||
|
||||
{/* 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">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Peso Bruto (kg)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...register('pesoBruto', { 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">Peso Neto (kg)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...register('pesoNeto', { 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">Volumen (m³)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...register('volumen', { 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">Cantidad 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 className="md:col-span-3">
|
||||
<label className="block text-sm font-medium text-gray-700">Descripción Mercancía</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"
|
||||
/>
|
||||
</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 })}
|
||||
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>
|
||||
|
||||
{/* 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"
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* Notas */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Notas</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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
{onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-white hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'Guardando...' : isEditing ? 'Actualizar' : 'Crear'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
213
src/features/ordenes-transporte/components/OTList.tsx
Normal file
213
src/features/ordenes-transporte/components/OTList.tsx
Normal file
@ -0,0 +1,213 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ordenesTransporteApi } from '../api/ordenes-transporte.api';
|
||||
import { OTStatusBadge } from './OTStatusBadge';
|
||||
import type { OrdenTransporte, OTFilters, EstadoOrdenTransporte, TipoCarga, ModalidadServicio } from '../types';
|
||||
|
||||
interface OTListProps {
|
||||
onSelect?: (ot: OrdenTransporte) => void;
|
||||
filters?: OTFilters;
|
||||
}
|
||||
|
||||
export function OTList({ onSelect, filters: externalFilters }: OTListProps) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [estadoFilter, setEstadoFilter] = useState<EstadoOrdenTransporte | ''>('');
|
||||
const [tipoCargaFilter, setTipoCargaFilter] = useState<TipoCarga | ''>('');
|
||||
const [modalidadFilter, setModalidadFilter] = useState<ModalidadServicio | ''>('');
|
||||
const limit = 10;
|
||||
|
||||
const filters: OTFilters = {
|
||||
...externalFilters,
|
||||
estado: estadoFilter || undefined,
|
||||
tipoCarga: tipoCargaFilter || undefined,
|
||||
modalidad: modalidadFilter || undefined,
|
||||
limit,
|
||||
offset: (page - 1) * limit,
|
||||
};
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['ordenes-transporte', filters],
|
||||
queryFn: () => ordenesTransporteApi.getAll(filters),
|
||||
});
|
||||
|
||||
const ordenes = data?.data || [];
|
||||
const total = data?.total || 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return '-';
|
||||
return new Date(dateString).toLocaleDateString('es-MX');
|
||||
};
|
||||
|
||||
const formatCurrency = (amount?: number) => {
|
||||
if (amount === undefined) return '-';
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN',
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
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}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
OT
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Cliente
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Origen / Destino
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Fecha Recolección
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Estado
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Tarifa
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
|
||||
Cargando órdenes de transporte...
|
||||
</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
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
ordenes.map((ot) => (
|
||||
<tr
|
||||
key={ot.id}
|
||||
onClick={() => onSelect?.(ot)}
|
||||
className="cursor-pointer hover:bg-gray-50"
|
||||
>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<div className="font-medium text-gray-900">{ot.numero}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{ot.modalidad} • {ot.tipoCarga?.replace(/_/g, ' ')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<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}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
→ {ot.destinoCiudad}, {ot.destinoEstado}
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
|
||||
{formatDate(ot.fechaRecoleccion)}
|
||||
</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>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<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}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||
>
|
||||
Siguiente
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
src/features/ordenes-transporte/components/OTStatusBadge.tsx
Normal file
35
src/features/ordenes-transporte/components/OTStatusBadge.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { EstadoOrdenTransporte } from '../types';
|
||||
|
||||
interface OTStatusBadgeProps {
|
||||
estado: EstadoOrdenTransporte;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const estadoConfig: Record<EstadoOrdenTransporte, { label: string; color: string }> = {
|
||||
[EstadoOrdenTransporte.BORRADOR]: { label: 'Borrador', color: 'bg-gray-100 text-gray-800' },
|
||||
[EstadoOrdenTransporte.PENDIENTE]: { label: 'Pendiente', color: 'bg-yellow-100 text-yellow-800' },
|
||||
[EstadoOrdenTransporte.CONFIRMADA]: { label: 'Confirmada', color: 'bg-blue-100 text-blue-800' },
|
||||
[EstadoOrdenTransporte.PROGRAMADA]: { label: 'Programada', color: 'bg-indigo-100 text-indigo-800' },
|
||||
[EstadoOrdenTransporte.EN_PROCESO]: { label: 'En Proceso', color: 'bg-purple-100 text-purple-800' },
|
||||
[EstadoOrdenTransporte.COMPLETADA]: { label: 'Completada', color: 'bg-green-100 text-green-800' },
|
||||
[EstadoOrdenTransporte.FACTURADA]: { label: 'Facturada', color: 'bg-teal-100 text-teal-800' },
|
||||
[EstadoOrdenTransporte.CANCELADA]: { label: 'Cancelada', color: 'bg-red-100 text-red-800' },
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-2 py-0.5 text-xs',
|
||||
md: 'px-2.5 py-1 text-sm',
|
||||
lg: 'px-3 py-1.5 text-base',
|
||||
};
|
||||
|
||||
export function OTStatusBadge({ estado, size = 'md' }: OTStatusBadgeProps) {
|
||||
const config = estadoConfig[estado] || { label: estado, color: 'bg-gray-100 text-gray-800' };
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full font-medium ${config.color} ${sizeClasses[size]}`}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
4
src/features/ordenes-transporte/components/index.ts
Normal file
4
src/features/ordenes-transporte/components/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { OTStatusBadge } from './OTStatusBadge';
|
||||
export { OTList } from './OTList';
|
||||
export { OTForm } from './OTForm';
|
||||
export { OTDetail } from './OTDetail';
|
||||
11
src/features/ordenes-transporte/index.ts
Normal file
11
src/features/ordenes-transporte/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
// Types
|
||||
export * from './types';
|
||||
|
||||
// API
|
||||
export { ordenesTransporteApi } from './api/ordenes-transporte.api';
|
||||
|
||||
// Components
|
||||
export { OTStatusBadge } from './components/OTStatusBadge';
|
||||
export { OTList } from './components/OTList';
|
||||
export { OTForm } from './components/OTForm';
|
||||
export { OTDetail } from './components/OTDetail';
|
||||
179
src/features/ordenes-transporte/types/index.ts
Normal file
179
src/features/ordenes-transporte/types/index.ts
Normal file
@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Ordenes de Transporte Types
|
||||
*/
|
||||
|
||||
export enum EstadoOrdenTransporte {
|
||||
BORRADOR = 'BORRADOR',
|
||||
PENDIENTE = 'PENDIENTE',
|
||||
SOLICITADA = 'SOLICITADA',
|
||||
CONFIRMADA = 'CONFIRMADA',
|
||||
ASIGNADA = 'ASIGNADA',
|
||||
EN_PROCESO = 'EN_PROCESO',
|
||||
EN_TRANSITO = 'EN_TRANSITO',
|
||||
COMPLETADA = 'COMPLETADA',
|
||||
ENTREGADA = 'ENTREGADA',
|
||||
FACTURADA = 'FACTURADA',
|
||||
CANCELADA = 'CANCELADA',
|
||||
}
|
||||
|
||||
export enum TipoCarga {
|
||||
GENERAL = 'GENERAL',
|
||||
PELIGROSA = 'PELIGROSA',
|
||||
REFRIGERADA = 'REFRIGERADA',
|
||||
SOBREDIMENSIONADA = 'SOBREDIMENSIONADA',
|
||||
GRANEL = 'GRANEL',
|
||||
LIQUIDOS = 'LIQUIDOS',
|
||||
CONTENEDOR = 'CONTENEDOR',
|
||||
AUTOMOVILES = 'AUTOMOVILES',
|
||||
}
|
||||
|
||||
export enum ModalidadServicio {
|
||||
FTL = 'FTL',
|
||||
LTL = 'LTL',
|
||||
DEDICADO = 'DEDICADO',
|
||||
EXPRESS = 'EXPRESS',
|
||||
CONSOLIDADO = 'CONSOLIDADO',
|
||||
}
|
||||
|
||||
export enum TipoEquipo {
|
||||
CAJA_SECA = 'CAJA_SECA',
|
||||
CAJA_REFRIGERADA = 'CAJA_REFRIGERADA',
|
||||
PLATAFORMA = 'PLATAFORMA',
|
||||
TANQUE = 'TANQUE',
|
||||
PORTACONTENEDOR = 'PORTACONTENEDOR',
|
||||
TORTON = 'TORTON',
|
||||
RABON = 'RABON',
|
||||
CAMIONETA = 'CAMIONETA',
|
||||
}
|
||||
|
||||
export interface OrdenTransporte {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
codigo: string;
|
||||
numeroOt?: string;
|
||||
referenciaCliente?: string;
|
||||
clienteId: string;
|
||||
shipperId?: string;
|
||||
shipperNombre: string;
|
||||
consigneeId: string;
|
||||
consigneeNombre: string;
|
||||
// Origen
|
||||
origenDireccion: string;
|
||||
origenCodigoPostal?: string;
|
||||
origenCiudad?: string;
|
||||
origenEstado?: string;
|
||||
origenLatitud?: number;
|
||||
origenLongitud?: number;
|
||||
origenContacto?: string;
|
||||
origenTelefono?: string;
|
||||
// Destino
|
||||
destinoDireccion: string;
|
||||
destinoCodigoPostal?: string;
|
||||
destinoCiudad?: string;
|
||||
destinoEstado?: string;
|
||||
destinoLatitud?: number;
|
||||
destinoLongitud?: number;
|
||||
destinoContacto?: string;
|
||||
destinoTelefono?: string;
|
||||
// Fechas
|
||||
fechaRecoleccion?: string;
|
||||
fechaRecoleccionProgramada?: string;
|
||||
fechaEntregaProgramada?: string;
|
||||
// Carga
|
||||
tipoCarga: TipoCarga;
|
||||
descripcionCarga?: string;
|
||||
pesoKg?: number;
|
||||
volumenM3?: number;
|
||||
piezas?: number;
|
||||
pallets?: number;
|
||||
valorDeclarado?: number;
|
||||
// Requisitos
|
||||
requiereTemperatura: boolean;
|
||||
temperaturaMin?: number;
|
||||
temperaturaMax?: number;
|
||||
requiereGps: boolean;
|
||||
requiereEscolta: boolean;
|
||||
instruccionesEspeciales?: string;
|
||||
observaciones?: string;
|
||||
// Servicio y tarifa
|
||||
modalidadServicio: ModalidadServicio;
|
||||
tarifaId?: string;
|
||||
tarifaBase?: number;
|
||||
recargos: number;
|
||||
descuentos: number;
|
||||
subtotal?: number;
|
||||
iva?: number;
|
||||
total?: number;
|
||||
// Estado y asignacion
|
||||
estado: EstadoOrdenTransporte;
|
||||
viajeId?: string;
|
||||
embarqueId?: string;
|
||||
// Auditoria
|
||||
createdAt: string;
|
||||
createdById: string;
|
||||
updatedAt: string;
|
||||
updatedById?: string;
|
||||
}
|
||||
|
||||
export interface OrdenTransporteFilters {
|
||||
search?: string;
|
||||
estado?: EstadoOrdenTransporte;
|
||||
estados?: EstadoOrdenTransporte[];
|
||||
clienteId?: string;
|
||||
modalidad?: ModalidadServicio;
|
||||
fechaDesde?: string;
|
||||
fechaHasta?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface OrdenesTransporteResponse {
|
||||
data: OrdenTransporte[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface CreateOrdenTransporteDto {
|
||||
referenciaCliente?: string;
|
||||
clienteId: string;
|
||||
shipperId?: string;
|
||||
shipperNombre: string;
|
||||
consigneeId: string;
|
||||
consigneeNombre: string;
|
||||
origenDireccion: string;
|
||||
origenCodigoPostal?: string;
|
||||
origenCiudad?: string;
|
||||
origenEstado?: string;
|
||||
origenLatitud?: number;
|
||||
origenLongitud?: number;
|
||||
origenContacto?: string;
|
||||
origenTelefono?: string;
|
||||
destinoDireccion: string;
|
||||
destinoCodigoPostal?: string;
|
||||
destinoCiudad?: string;
|
||||
destinoEstado?: string;
|
||||
destinoLatitud?: number;
|
||||
destinoLongitud?: number;
|
||||
destinoContacto?: string;
|
||||
destinoTelefono?: string;
|
||||
fechaRecoleccionProgramada?: string;
|
||||
fechaEntregaProgramada?: string;
|
||||
tipoCarga?: TipoCarga;
|
||||
descripcionCarga?: string;
|
||||
pesoKg?: number;
|
||||
volumenM3?: number;
|
||||
piezas?: number;
|
||||
pallets?: number;
|
||||
valorDeclarado?: number;
|
||||
requiereTemperatura?: boolean;
|
||||
temperaturaMin?: number;
|
||||
temperaturaMax?: number;
|
||||
requiereGps?: boolean;
|
||||
requiereEscolta?: boolean;
|
||||
instruccionesEspeciales?: string;
|
||||
modalidadServicio?: ModalidadServicio;
|
||||
}
|
||||
|
||||
export interface UpdateOrdenTransporteDto extends Partial<CreateOrdenTransporteDto> {
|
||||
estado?: EstadoOrdenTransporte;
|
||||
observaciones?: string;
|
||||
}
|
||||
129
src/features/tracking/api/tracking.api.ts
Normal file
129
src/features/tracking/api/tracking.api.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import { api } from '@/services/api/axios-instance';
|
||||
import type {
|
||||
EventoTracking,
|
||||
EventoFilters,
|
||||
EventosResponse,
|
||||
Geocerca,
|
||||
GeocercaFilters,
|
||||
GeocercasResponse,
|
||||
CreateGeocercaDto,
|
||||
UpdateGeocercaDto,
|
||||
PosicionActual,
|
||||
} from '../types';
|
||||
|
||||
const BASE_URL = '/api/v1/tracking';
|
||||
|
||||
export const trackingApi = {
|
||||
// ==================== Eventos ====================
|
||||
|
||||
// Get eventos with filters
|
||||
getEventos: async (filters?: EventoFilters): Promise<EventosResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.unidadId) params.append('unidadId', filters.unidadId);
|
||||
if (filters?.viajeId) params.append('viajeId', filters.viajeId);
|
||||
if (filters?.tipoEvento) params.append('tipoEvento', filters.tipoEvento);
|
||||
if (filters?.fechaDesde) params.append('fechaDesde', filters.fechaDesde);
|
||||
if (filters?.fechaHasta) params.append('fechaHasta', filters.fechaHasta);
|
||||
if (filters?.limit) params.append('limit', String(filters.limit));
|
||||
if (filters?.offset) params.append('offset', String(filters.offset));
|
||||
|
||||
const response = await api.get<EventosResponse>(`${BASE_URL}/eventos?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Registrar evento
|
||||
registrarEvento: async (data: Partial<EventoTracking>): Promise<{ data: EventoTracking }> => {
|
||||
const response = await api.post<{ data: EventoTracking }>(`${BASE_URL}/eventos`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Registrar posicion GPS
|
||||
registrarPosicion: async (data: {
|
||||
unidadId: string;
|
||||
latitud: number;
|
||||
longitud: number;
|
||||
velocidad?: number;
|
||||
rumbo?: number;
|
||||
odometro?: number;
|
||||
viajeId?: string;
|
||||
operadorId?: string;
|
||||
}): Promise<{ data: EventoTracking }> => {
|
||||
const response = await api.post<{ data: EventoTracking }>(`${BASE_URL}/posicion`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get ultima posicion de una unidad
|
||||
getUltimaPosicion: async (unidadId: string): Promise<{ data: EventoTracking | null }> => {
|
||||
const response = await api.get<{ data: EventoTracking | null }>(`${BASE_URL}/unidad/${unidadId}/ultima-posicion`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get historial de posiciones
|
||||
getHistorialPosiciones: async (
|
||||
unidadId: string,
|
||||
fechaDesde: string,
|
||||
fechaHasta: string
|
||||
): Promise<{ data: EventoTracking[] }> => {
|
||||
const response = await api.get<{ data: EventoTracking[] }>(
|
||||
`${BASE_URL}/unidad/${unidadId}/historial?fechaDesde=${fechaDesde}&fechaHasta=${fechaHasta}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get ruta de viaje
|
||||
getRutaViaje: async (viajeId: string): Promise<{ data: EventoTracking[] }> => {
|
||||
const response = await api.get<{ data: EventoTracking[] }>(`${BASE_URL}/viaje/${viajeId}/ruta`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get eventos de viaje
|
||||
getEventosViaje: async (viajeId: string): Promise<{ data: EventoTracking[] }> => {
|
||||
const response = await api.get<{ data: EventoTracking[] }>(`${BASE_URL}/viaje/${viajeId}/eventos`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get posiciones actuales de todas las unidades
|
||||
getPosicionesActuales: async (unidadIds?: string[]): Promise<{ data: PosicionActual[] }> => {
|
||||
const params = unidadIds ? `?unidadIds=${unidadIds.join(',')}` : '';
|
||||
const response = await api.get<{ data: PosicionActual[] }>(`${BASE_URL}/posiciones-actuales${params}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// ==================== Geocercas ====================
|
||||
|
||||
// Get all geocercas
|
||||
getGeocercas: async (filters?: GeocercaFilters): Promise<GeocercasResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.tipo) params.append('tipo', filters.tipo);
|
||||
if (filters?.activa !== undefined) params.append('activa', String(filters.activa));
|
||||
if (filters?.clienteId) params.append('clienteId', filters.clienteId);
|
||||
if (filters?.limit) params.append('limit', String(filters.limit));
|
||||
if (filters?.offset) params.append('offset', String(filters.offset));
|
||||
|
||||
const response = await api.get<GeocercasResponse>(`${BASE_URL}/geocercas?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get geocerca by ID
|
||||
getGeocercaById: async (id: string): Promise<{ data: Geocerca }> => {
|
||||
const response = await api.get<{ data: Geocerca }>(`${BASE_URL}/geocercas/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create geocerca
|
||||
createGeocerca: async (data: CreateGeocercaDto): Promise<{ data: Geocerca }> => {
|
||||
const response = await api.post<{ data: Geocerca }>(`${BASE_URL}/geocercas`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update geocerca
|
||||
updateGeocerca: async (id: string, data: UpdateGeocercaDto): Promise<{ data: Geocerca }> => {
|
||||
const response = await api.patch<{ data: Geocerca }>(`${BASE_URL}/geocercas/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete geocerca
|
||||
deleteGeocerca: async (id: string): Promise<void> => {
|
||||
await api.delete(`${BASE_URL}/geocercas/${id}`);
|
||||
},
|
||||
};
|
||||
184
src/features/tracking/components/EventosList.tsx
Normal file
184
src/features/tracking/components/EventosList.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { trackingApi } from '../api/tracking.api';
|
||||
import type { EventoTracking, EventoTrackingFilters, TipoEventoTracking } from '../types';
|
||||
|
||||
interface EventosListProps {
|
||||
viajeId?: string;
|
||||
unidadId?: string;
|
||||
onSelect?: (evento: EventoTracking) => void;
|
||||
}
|
||||
|
||||
const tipoEventoLabels: Record<TipoEventoTracking, string> = {
|
||||
POSICION: 'Posición',
|
||||
ENCENDIDO: 'Encendido',
|
||||
APAGADO: 'Apagado',
|
||||
VELOCIDAD_EXCESIVA: 'Velocidad Excesiva',
|
||||
ENTRADA_GEOCERCA: 'Entrada Geocerca',
|
||||
SALIDA_GEOCERCA: 'Salida Geocerca',
|
||||
PARADA_PROLONGADA: 'Parada Prolongada',
|
||||
DESVIO_RUTA: 'Desvío de Ruta',
|
||||
BOTON_PANICO: 'Botón de Pánico',
|
||||
BATERIA_BAJA: 'Batería Baja',
|
||||
DESCONEXION_GPS: 'Desconexión GPS',
|
||||
RECONEXION_GPS: 'Reconexión GPS',
|
||||
};
|
||||
|
||||
const tipoEventoColors: Record<TipoEventoTracking, string> = {
|
||||
POSICION: 'bg-gray-100 text-gray-800',
|
||||
ENCENDIDO: 'bg-green-100 text-green-800',
|
||||
APAGADO: 'bg-red-100 text-red-800',
|
||||
VELOCIDAD_EXCESIVA: 'bg-orange-100 text-orange-800',
|
||||
ENTRADA_GEOCERCA: 'bg-blue-100 text-blue-800',
|
||||
SALIDA_GEOCERCA: 'bg-purple-100 text-purple-800',
|
||||
PARADA_PROLONGADA: 'bg-yellow-100 text-yellow-800',
|
||||
DESVIO_RUTA: 'bg-pink-100 text-pink-800',
|
||||
BOTON_PANICO: 'bg-red-200 text-red-900',
|
||||
BATERIA_BAJA: 'bg-amber-100 text-amber-800',
|
||||
DESCONEXION_GPS: 'bg-gray-200 text-gray-900',
|
||||
RECONEXION_GPS: 'bg-teal-100 text-teal-800',
|
||||
};
|
||||
|
||||
export function EventosList({ viajeId, unidadId, onSelect }: EventosListProps) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [tipoFilter, setTipoFilter] = useState<TipoEventoTracking | ''>('');
|
||||
const limit = 20;
|
||||
|
||||
const filters: EventoTrackingFilters = {
|
||||
viajeId,
|
||||
unidadId,
|
||||
tipo: tipoFilter || undefined,
|
||||
limit,
|
||||
offset: (page - 1) * limit,
|
||||
};
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['tracking-eventos', filters],
|
||||
queryFn: () => trackingApi.getEventos(filters),
|
||||
});
|
||||
|
||||
const eventos = data?.data || [];
|
||||
const total = data?.total || 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
const formatDateTime = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString('es-MX', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'medium',
|
||||
});
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-lg bg-red-50 p-4 text-red-700">
|
||||
Error al cargar eventos: {(error as Error).message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-4">
|
||||
<select
|
||||
value={tipoFilter}
|
||||
onChange={(e) => setTipoFilter(e.target.value as TipoEventoTracking | '')}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Todos los tipos</option>
|
||||
{Object.entries(tipoEventoLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="text-sm text-gray-500">
|
||||
{total} evento{total !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events List */}
|
||||
<div className="space-y-2">
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-center text-gray-500">Cargando eventos...</div>
|
||||
) : eventos.length === 0 ? (
|
||||
<div className="py-8 text-center text-gray-500">No hay eventos para mostrar</div>
|
||||
) : (
|
||||
eventos.map((evento) => (
|
||||
<div
|
||||
key={evento.id}
|
||||
onClick={() => onSelect?.(evento)}
|
||||
className="cursor-pointer rounded-lg border border-gray-200 bg-white p-3 hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
tipoEventoColors[evento.tipo] || 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{tipoEventoLabels[evento.tipo] || evento.tipo}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{formatDateTime(evento.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{evento.fuente}</span>
|
||||
</div>
|
||||
|
||||
{/* Location info */}
|
||||
{evento.latitud && evento.longitud && (
|
||||
<div className="mt-2 text-sm text-gray-600">
|
||||
<span className="font-mono">
|
||||
{evento.latitud.toFixed(6)}, {evento.longitud.toFixed(6)}
|
||||
</span>
|
||||
{evento.velocidad !== undefined && (
|
||||
<span className="ml-3">{evento.velocidad.toFixed(0)} km/h</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Additional data */}
|
||||
{evento.datosExtra && Object.keys(evento.datosExtra).length > 0 && (
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
{Object.entries(evento.datosExtra)
|
||||
.slice(0, 3)
|
||||
.map(([key, value]) => (
|
||||
<span key={key} className="mr-3">
|
||||
{key}: {String(value)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
Página {page} de {totalPages}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||
>
|
||||
Siguiente
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
241
src/features/tracking/components/GeocercasList.tsx
Normal file
241
src/features/tracking/components/GeocercasList.tsx
Normal file
@ -0,0 +1,241 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { trackingApi } from '../api/tracking.api';
|
||||
import type { Geocerca, GeocercaFilters, TipoGeocerca } from '../types';
|
||||
|
||||
interface GeocercasListProps {
|
||||
onSelect?: (geocerca: Geocerca) => void;
|
||||
onEdit?: (geocerca: Geocerca) => void;
|
||||
}
|
||||
|
||||
const tipoGeocercaLabels: Record<TipoGeocerca, string> = {
|
||||
CLIENTE: 'Cliente',
|
||||
ALMACEN: 'Almacén',
|
||||
GASOLINERA: 'Gasolinera',
|
||||
CASETA: 'Caseta',
|
||||
PUNTO_CONTROL: 'Punto de Control',
|
||||
ZONA_RIESGO: 'Zona de Riesgo',
|
||||
ZONA_DESCANSO: 'Zona de Descanso',
|
||||
FRONTERA: 'Frontera',
|
||||
ADUANA: 'Aduana',
|
||||
PUERTO: 'Puerto',
|
||||
};
|
||||
|
||||
const tipoGeocercaIcons: Record<TipoGeocerca, string> = {
|
||||
CLIENTE: '🏢',
|
||||
ALMACEN: '📦',
|
||||
GASOLINERA: '⛽',
|
||||
CASETA: '🚧',
|
||||
PUNTO_CONTROL: '✓',
|
||||
ZONA_RIESGO: '⚠️',
|
||||
ZONA_DESCANSO: '🅿️',
|
||||
FRONTERA: '🚩',
|
||||
ADUANA: '🛃',
|
||||
PUERTO: '⚓',
|
||||
};
|
||||
|
||||
export function GeocercasList({ onSelect, onEdit }: GeocercasListProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const [tipoFilter, setTipoFilter] = useState<TipoGeocerca | ''>('');
|
||||
const [activoFilter, setActivoFilter] = useState<boolean | ''>('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const limit = 10;
|
||||
|
||||
const filters: GeocercaFilters = {
|
||||
tipo: tipoFilter || undefined,
|
||||
activo: activoFilter !== '' ? activoFilter : undefined,
|
||||
search: searchTerm || undefined,
|
||||
limit,
|
||||
offset: (page - 1) * limit,
|
||||
};
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['geocercas', filters],
|
||||
queryFn: () => trackingApi.getGeocercas(filters),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => trackingApi.deleteGeocerca(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['geocercas'] });
|
||||
},
|
||||
});
|
||||
|
||||
const geocercas = data?.data || [];
|
||||
const total = data?.total || 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
const handleDelete = async (geocerca: Geocerca) => {
|
||||
if (window.confirm(`¿Eliminar la geocerca "${geocerca.nombre}"?`)) {
|
||||
await deleteMutation.mutateAsync(geocerca.id);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-lg bg-red-50 p-4 text-red-700">
|
||||
Error al cargar geocercas: {(error as Error).message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Buscar por nombre..."
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
value={tipoFilter}
|
||||
onChange={(e) => setTipoFilter(e.target.value as TipoGeocerca | '')}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Todos los tipos</option>
|
||||
{Object.entries(tipoGeocercaLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={activoFilter === '' ? '' : activoFilter ? 'true' : 'false'}
|
||||
onChange={(e) =>
|
||||
setActivoFilter(e.target.value === '' ? '' : e.target.value === 'true')
|
||||
}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
<option value="true">Activas</option>
|
||||
<option value="false">Inactivas</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Geocerca
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Tipo
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Radio
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Estado
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
|
||||
Cargando geocercas...
|
||||
</td>
|
||||
</tr>
|
||||
) : geocercas.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
|
||||
No hay geocercas para mostrar
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
geocercas.map((geocerca) => (
|
||||
<tr
|
||||
key={geocerca.id}
|
||||
onClick={() => onSelect?.(geocerca)}
|
||||
className="cursor-pointer hover:bg-gray-50"
|
||||
>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<div className="font-medium text-gray-900">{geocerca.nombre}</div>
|
||||
{geocerca.descripcion && (
|
||||
<div className="text-sm text-gray-500 line-clamp-1">
|
||||
{geocerca.descripcion}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<span className="mr-1">{tipoGeocercaIcons[geocerca.tipo]}</span>
|
||||
{tipoGeocercaLabels[geocerca.tipo] || geocerca.tipo}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
|
||||
{geocerca.radio ? `${geocerca.radio} m` : 'Polígono'}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
geocerca.activo
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{geocerca.activo ? 'Activa' : 'Inactiva'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-right text-sm">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit?.(geocerca);
|
||||
}}
|
||||
className="mr-2 text-primary-600 hover:text-primary-800"
|
||||
>
|
||||
Editar
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(geocerca);
|
||||
}}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<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}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||
>
|
||||
Siguiente
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
src/features/tracking/components/TrackingMap.tsx
Normal file
160
src/features/tracking/components/TrackingMap.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { trackingApi } from '../api/tracking.api';
|
||||
import type { PosicionActual, EventoTracking } from '../types';
|
||||
|
||||
interface TrackingMapProps {
|
||||
unidadIds?: string[];
|
||||
viajeId?: string;
|
||||
showRoute?: boolean;
|
||||
refreshInterval?: number;
|
||||
}
|
||||
|
||||
export function TrackingMap({
|
||||
unidadIds,
|
||||
viajeId,
|
||||
showRoute = false,
|
||||
refreshInterval = 30000,
|
||||
}: TrackingMapProps) {
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedUnidad, setSelectedUnidad] = useState<string | null>(null);
|
||||
|
||||
// Fetch current positions
|
||||
const { data: posiciones, isLoading: loadingPosiciones } = useQuery({
|
||||
queryKey: ['tracking-posiciones', unidadIds],
|
||||
queryFn: () => trackingApi.getPosicionesActuales(unidadIds),
|
||||
refetchInterval: refreshInterval,
|
||||
enabled: !viajeId,
|
||||
});
|
||||
|
||||
// Fetch route for viaje
|
||||
const { data: ruta, isLoading: loadingRuta } = useQuery({
|
||||
queryKey: ['tracking-ruta', viajeId],
|
||||
queryFn: () => trackingApi.getRutaViaje(viajeId!),
|
||||
enabled: !!viajeId && showRoute,
|
||||
});
|
||||
|
||||
const posicionesData = posiciones?.data || [];
|
||||
const rutaData = ruta?.data || [];
|
||||
|
||||
// Simple map rendering (placeholder - integrate with actual map library)
|
||||
const renderPosition = (pos: PosicionActual, index: number) => {
|
||||
const isSelected = selectedUnidad === pos.unidadId;
|
||||
return (
|
||||
<div
|
||||
key={pos.unidadId}
|
||||
onClick={() => setSelectedUnidad(isSelected ? null : pos.unidadId)}
|
||||
className={`absolute cursor-pointer rounded-full p-2 transition-all ${
|
||||
isSelected ? 'scale-125 bg-primary-600' : 'bg-blue-500'
|
||||
}`}
|
||||
style={{
|
||||
// Simple positioning based on lat/lng (for demo - use real map library)
|
||||
left: `${((pos.longitud + 180) / 360) * 100}%`,
|
||||
top: `${((90 - pos.latitud) / 180) * 100}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
title={pos.numeroEconomico}
|
||||
>
|
||||
<svg className="h-4 w-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 2a6 6 0 00-6 6c0 4.5 6 10 6 10s6-5.5 6-10a6 6 0 00-6-6zm0 8a2 2 0 110-4 2 2 0 010 4z" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-hidden rounded-lg border border-gray-200 bg-gray-100">
|
||||
{/* Map Area */}
|
||||
<div ref={mapRef} className="relative h-full w-full">
|
||||
{loadingPosiciones || loadingRuta ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-gray-500">Cargando mapa...</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Placeholder map background */}
|
||||
<div className="h-full w-full bg-gradient-to-br from-blue-100 to-green-100" />
|
||||
|
||||
{/* Positions */}
|
||||
{posicionesData.map(renderPosition)}
|
||||
|
||||
{/* Route polyline placeholder */}
|
||||
{showRoute && rutaData.length > 0 && (
|
||||
<svg className="absolute inset-0 h-full w-full">
|
||||
<polyline
|
||||
points={rutaData
|
||||
.filter((e) => e.latitud && e.longitud)
|
||||
.map(
|
||||
(e) =>
|
||||
`${((e.longitud! + 180) / 360) * 100}%,${((90 - e.latitud!) / 180) * 100}%`
|
||||
)
|
||||
.join(' ')}
|
||||
fill="none"
|
||||
stroke="#3B82F6"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected unit info */}
|
||||
{selectedUnidad && (
|
||||
<div className="absolute bottom-4 left-4 rounded-lg bg-white p-4 shadow-lg">
|
||||
{(() => {
|
||||
const pos = posicionesData.find((p) => p.unidadId === selectedUnidad);
|
||||
if (!pos) return null;
|
||||
return (
|
||||
<>
|
||||
<h3 className="font-medium text-gray-900">{pos.numeroEconomico}</h3>
|
||||
<div className="mt-2 space-y-1 text-sm text-gray-500">
|
||||
<div>Velocidad: {pos.velocidad?.toFixed(0) || 0} km/h</div>
|
||||
<div>Rumbo: {pos.rumbo?.toFixed(0) || 0}°</div>
|
||||
<div>
|
||||
Actualizado:{' '}
|
||||
{new Date(pos.timestamp).toLocaleTimeString('es-MX')}
|
||||
</div>
|
||||
{pos.operadorNombre && <div>Operador: {pos.operadorNombre}</div>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedUnidad(null)}
|
||||
className="mt-2 text-sm text-primary-600 hover:text-primary-800"
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Map controls placeholder */}
|
||||
<div className="absolute right-4 top-4 flex flex-col gap-2">
|
||||
<button className="rounded-lg bg-white p-2 shadow hover:bg-gray-50">
|
||||
<svg className="h-5 w-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="rounded-lg bg-white p-2 shadow hover:bg-gray-50">
|
||||
<svg className="h-5 w-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="absolute bottom-4 right-4 rounded-lg bg-white p-3 text-xs shadow">
|
||||
<div className="mb-1 font-medium text-gray-700">Unidades: {posicionesData.length}</div>
|
||||
<div className="text-gray-500">
|
||||
Actualizando cada {refreshInterval / 1000}s
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Note about map integration */}
|
||||
<div className="absolute left-4 top-4 rounded bg-yellow-100 px-2 py-1 text-xs text-yellow-800">
|
||||
Integrar con Mapbox/Google Maps/Leaflet
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
src/features/tracking/components/index.ts
Normal file
3
src/features/tracking/components/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { TrackingMap } from './TrackingMap';
|
||||
export { EventosList } from './EventosList';
|
||||
export { GeocercasList } from './GeocercasList';
|
||||
10
src/features/tracking/index.ts
Normal file
10
src/features/tracking/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
// Types
|
||||
export * from './types';
|
||||
|
||||
// API
|
||||
export { trackingApi } from './api/tracking.api';
|
||||
|
||||
// Components
|
||||
export { TrackingMap } from './components/TrackingMap';
|
||||
export { EventosList } from './components/EventosList';
|
||||
export { GeocercasList } from './components/GeocercasList';
|
||||
163
src/features/tracking/types/index.ts
Normal file
163
src/features/tracking/types/index.ts
Normal file
@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Tracking Types - Eventos GPS y Geocercas
|
||||
*/
|
||||
|
||||
export enum TipoEventoTracking {
|
||||
POSICION = 'POSICION',
|
||||
SALIDA = 'SALIDA',
|
||||
ARRIBO_ORIGEN = 'ARRIBO_ORIGEN',
|
||||
INICIO_CARGA = 'INICIO_CARGA',
|
||||
FIN_CARGA = 'FIN_CARGA',
|
||||
ARRIBO_DESTINO = 'ARRIBO_DESTINO',
|
||||
INICIO_DESCARGA = 'INICIO_DESCARGA',
|
||||
FIN_DESCARGA = 'FIN_DESCARGA',
|
||||
ENTREGA_POD = 'ENTREGA_POD',
|
||||
DESVIO = 'DESVIO',
|
||||
PARADA = 'PARADA',
|
||||
INCIDENTE = 'INCIDENTE',
|
||||
GPS_POSICION = 'GPS_POSICION',
|
||||
GEOCERCA_ENTRADA = 'GEOCERCA_ENTRADA',
|
||||
GEOCERCA_SALIDA = 'GEOCERCA_SALIDA',
|
||||
}
|
||||
|
||||
export enum FuenteEvento {
|
||||
GPS = 'GPS',
|
||||
APP_OPERADOR = 'APP_OPERADOR',
|
||||
SISTEMA = 'SISTEMA',
|
||||
MANUAL = 'MANUAL',
|
||||
GEOCERCA = 'GEOCERCA',
|
||||
}
|
||||
|
||||
export interface EventoTracking {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
viajeId?: string;
|
||||
unidadId?: string;
|
||||
operadorId?: string;
|
||||
tipoEvento: TipoEventoTracking;
|
||||
fuente: FuenteEvento;
|
||||
latitud?: number;
|
||||
longitud?: number;
|
||||
direccion?: string;
|
||||
timestamp: string;
|
||||
timestampEvento?: string;
|
||||
timestampRegistro: string;
|
||||
velocidad?: number;
|
||||
rumbo?: number;
|
||||
altitud?: number;
|
||||
precision?: number;
|
||||
odometro?: number;
|
||||
nivelCombustible?: number;
|
||||
motorEncendido?: boolean;
|
||||
descripcion?: string;
|
||||
datosAdicionales?: Record<string, any>;
|
||||
datos?: Record<string, any>;
|
||||
paradaId?: string;
|
||||
generadoPorId?: string;
|
||||
generadoPorTipo?: string;
|
||||
evidencias?: Record<string, any>;
|
||||
observaciones?: string;
|
||||
}
|
||||
|
||||
export interface EventoFilters {
|
||||
unidadId?: string;
|
||||
viajeId?: string;
|
||||
tipoEvento?: TipoEventoTracking;
|
||||
fechaDesde?: string;
|
||||
fechaHasta?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface EventosResponse {
|
||||
data: EventoTracking[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ==================== Geocercas ====================
|
||||
|
||||
export enum TipoGeocerca {
|
||||
CIRCULAR = 'CIRCULAR',
|
||||
POLIGONAL = 'POLIGONAL',
|
||||
CLIENTE = 'CLIENTE',
|
||||
PROVEEDOR = 'PROVEEDOR',
|
||||
PATIO = 'PATIO',
|
||||
ZONA_RIESGO = 'ZONA_RIESGO',
|
||||
CASETA = 'CASETA',
|
||||
GASOLINERA = 'GASOLINERA',
|
||||
PUNTO_CONTROL = 'PUNTO_CONTROL',
|
||||
OTRO = 'OTRO',
|
||||
}
|
||||
|
||||
export interface Geocerca {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
codigo: string;
|
||||
nombre: string;
|
||||
tipo: TipoGeocerca;
|
||||
esCircular: boolean;
|
||||
centroLatitud?: number;
|
||||
centroLongitud?: number;
|
||||
radioMetros?: number;
|
||||
radio?: number;
|
||||
poligono?: string;
|
||||
geometria?: GeoJSON.Geometry;
|
||||
clienteId?: string;
|
||||
direccion?: string;
|
||||
alertaEntrada: boolean;
|
||||
alertaSalida: boolean;
|
||||
tiempoPermanenciaMinutos?: number;
|
||||
color: string;
|
||||
activa: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface GeocercaFilters {
|
||||
tipo?: TipoGeocerca;
|
||||
activa?: boolean;
|
||||
clienteId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface GeocercasResponse {
|
||||
data: Geocerca[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface CreateGeocercaDto {
|
||||
codigo: string;
|
||||
nombre: string;
|
||||
tipo: TipoGeocerca;
|
||||
esCircular?: boolean;
|
||||
centroLatitud?: number;
|
||||
centroLongitud?: number;
|
||||
radioMetros?: number;
|
||||
poligono?: string;
|
||||
geometria?: GeoJSON.Geometry;
|
||||
clienteId?: string;
|
||||
direccion?: string;
|
||||
alertaEntrada?: boolean;
|
||||
alertaSalida?: boolean;
|
||||
tiempoPermanenciaMinutos?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface UpdateGeocercaDto extends Partial<CreateGeocercaDto> {
|
||||
activa?: boolean;
|
||||
}
|
||||
|
||||
// ==================== Posicion Actual ====================
|
||||
|
||||
export interface PosicionActual {
|
||||
unidadId: string;
|
||||
numeroEconomico: string;
|
||||
latitud: number;
|
||||
longitud: number;
|
||||
velocidad?: number;
|
||||
rumbo?: number;
|
||||
timestamp: string;
|
||||
operadorNombre?: string;
|
||||
estado: string;
|
||||
}
|
||||
100
src/features/viajes/api/viajes.api.ts
Normal file
100
src/features/viajes/api/viajes.api.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { api } from '@/services/api/axios-instance';
|
||||
import type {
|
||||
Viaje,
|
||||
ViajeFilters,
|
||||
ViajesResponse,
|
||||
CreateViajeDto,
|
||||
UpdateViajeDto,
|
||||
DatosEntrega,
|
||||
} from '../types';
|
||||
|
||||
const BASE_URL = '/api/v1/viajes';
|
||||
|
||||
export const viajesApi = {
|
||||
// Get all viajes with filters
|
||||
getAll: async (filters?: ViajeFilters): Promise<ViajesResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.search) params.append('search', filters.search);
|
||||
if (filters?.estado) params.append('estado', filters.estado);
|
||||
if (filters?.estados) params.append('estados', filters.estados.join(','));
|
||||
if (filters?.unidadId) params.append('unidadId', filters.unidadId);
|
||||
if (filters?.operadorId) params.append('operadorId', filters.operadorId);
|
||||
if (filters?.clienteId) params.append('clienteId', filters.clienteId);
|
||||
if (filters?.fechaDesde) params.append('fechaDesde', filters.fechaDesde);
|
||||
if (filters?.fechaHasta) params.append('fechaHasta', filters.fechaHasta);
|
||||
if (filters?.limit) params.append('limit', String(filters.limit));
|
||||
if (filters?.offset) params.append('offset', String(filters.offset));
|
||||
|
||||
const response = await api.get<ViajesResponse>(`${BASE_URL}?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get viaje by ID
|
||||
getById: async (id: string): Promise<{ data: Viaje }> => {
|
||||
const response = await api.get<{ data: Viaje }>(`${BASE_URL}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get viaje by numero
|
||||
getByNumero: async (numeroViaje: string): Promise<{ data: Viaje }> => {
|
||||
const response = await api.get<{ data: Viaje }>(`${BASE_URL}/numero/${numeroViaje}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create viaje
|
||||
create: async (data: CreateViajeDto): Promise<{ data: Viaje }> => {
|
||||
const response = await api.post<{ data: Viaje }>(BASE_URL, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update viaje
|
||||
update: async (id: string, data: UpdateViajeDto): Promise<{ data: Viaje }> => {
|
||||
const response = await api.patch<{ data: Viaje }>(`${BASE_URL}/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// State operations
|
||||
despachar: async (id: string, kmInicio: number): Promise<{ data: Viaje }> => {
|
||||
const response = await api.post<{ data: Viaje }>(`${BASE_URL}/${id}/despachar`, { kmInicio });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
iniciarTransito: async (id: string): Promise<{ data: Viaje }> => {
|
||||
const response = await api.post<{ data: Viaje }>(`${BASE_URL}/${id}/iniciar-transito`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
registrarEntrega: async (id: string, datos: DatosEntrega): Promise<{ data: Viaje }> => {
|
||||
const response = await api.post<{ data: Viaje }>(`${BASE_URL}/${id}/registrar-entrega`, datos);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
cerrar: async (id: string, kmFin: number): Promise<{ data: Viaje }> => {
|
||||
const response = await api.post<{ data: Viaje }>(`${BASE_URL}/${id}/cerrar`, { kmFin });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
cancelar: async (id: string, motivo: string): Promise<{ data: Viaje }> => {
|
||||
const response = await api.post<{ data: Viaje }>(`${BASE_URL}/${id}/cancelar`, { motivo });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get viajes en progreso
|
||||
getEnProgreso: async (): Promise<{ data: Viaje[] }> => {
|
||||
const response = await api.get<{ data: Viaje[] }>(`${BASE_URL}/en-progreso`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get viajes para planificacion
|
||||
getParaPlanificacion: async (fecha: string): Promise<{ data: Viaje[] }> => {
|
||||
const response = await api.get<{ data: Viaje[] }>(`${BASE_URL}/planificacion?fecha=${fecha}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get viajes por operador
|
||||
getByOperador: async (operadorId: string, limit?: number): Promise<{ data: Viaje[] }> => {
|
||||
const params = limit ? `?limit=${limit}` : '';
|
||||
const response = await api.get<{ data: Viaje[] }>(`${BASE_URL}/operador/${operadorId}${params}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
266
src/features/viajes/components/ViajeDetail.tsx
Normal file
266
src/features/viajes/components/ViajeDetail.tsx
Normal file
@ -0,0 +1,266 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { viajesApi } from '../api/viajes.api';
|
||||
import { ViajeStatusBadge } from './ViajeStatusBadge';
|
||||
import type { Viaje, EstadoViaje } from '../types';
|
||||
|
||||
interface ViajeDetailProps {
|
||||
viaje: Viaje;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ViajeDetail({ viaje, onClose }: ViajeDetailProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [kmInicio, setKmInicio] = useState<number>(viaje.kmInicio || 0);
|
||||
const [kmFin, setKmFin] = useState<number>(viaje.kmFin || 0);
|
||||
const [motivoCancelacion, setMotivoCancelacion] = useState('');
|
||||
|
||||
const despacharMutation = useMutation({
|
||||
mutationFn: () => viajesApi.despachar(viaje.id, kmInicio),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['viajes'] }),
|
||||
});
|
||||
|
||||
const iniciarTransitoMutation = useMutation({
|
||||
mutationFn: () => viajesApi.iniciarTransito(viaje.id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['viajes'] }),
|
||||
});
|
||||
|
||||
const cerrarMutation = useMutation({
|
||||
mutationFn: () => viajesApi.cerrar(viaje.id, kmFin),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['viajes'] }),
|
||||
});
|
||||
|
||||
const cancelarMutation = useMutation({
|
||||
mutationFn: () => viajesApi.cancelar(viaje.id, motivoCancelacion),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['viajes'] }),
|
||||
});
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return '-';
|
||||
return new Date(dateString).toLocaleDateString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return `$${amount.toLocaleString('es-MX', { minimumFractionDigits: 2 })}`;
|
||||
};
|
||||
|
||||
const canDespachar = viaje.estado === 'PLANEADO';
|
||||
const canIniciarTransito = viaje.estado === 'DESPACHADO';
|
||||
const canCerrar = viaje.estado === 'ENTREGADO';
|
||||
const canCancelar = !['CERRADO', 'FACTURADO', 'COBRADO', 'CANCELADO'].includes(viaje.estado);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg bg-white p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
Viaje {viaje.codigo}
|
||||
</h2>
|
||||
{viaje.numeroViaje && (
|
||||
<p className="text-sm text-gray-500">{viaje.numeroViaje}</p>
|
||||
)}
|
||||
</div>
|
||||
<ViajeStatusBadge estado={viaje.estado} size="lg" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-6">
|
||||
{/* Ruta */}
|
||||
<div className="rounded-lg bg-gray-50 p-4">
|
||||
<h3 className="mb-2 font-medium text-gray-900">Ruta</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Origen:</span>
|
||||
<p className="font-medium">{viaje.origenCiudad || viaje.origenPrincipal || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Destino:</span>
|
||||
<p className="font-medium">{viaje.destinoCiudad || viaje.destinoPrincipal || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Distancia:</span>
|
||||
<p className="font-medium">{viaje.distanciaEstimadaKm ? `${viaje.distanciaEstimadaKm} km` : '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Tiempo estimado:</span>
|
||||
<p className="font-medium">{viaje.tiempoEstimadoHoras ? `${viaje.tiempoEstimadoHoras} hrs` : '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fechas */}
|
||||
<div className="rounded-lg bg-gray-50 p-4">
|
||||
<h3 className="mb-2 font-medium text-gray-900">Fechas</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Salida programada:</span>
|
||||
<p className="font-medium">{formatDate(viaje.fechaSalidaProgramada)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Llegada programada:</span>
|
||||
<p className="font-medium">{formatDate(viaje.fechaLlegadaProgramada)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Salida real:</span>
|
||||
<p className="font-medium">{formatDate(viaje.fechaSalidaReal || viaje.fechaRealSalida)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Llegada real:</span>
|
||||
<p className="font-medium">{formatDate(viaje.fechaLlegadaReal || viaje.fechaRealLlegada)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kilometraje */}
|
||||
<div className="rounded-lg bg-gray-50 p-4">
|
||||
<h3 className="mb-2 font-medium text-gray-900">Kilometraje</h3>
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Km inicio:</span>
|
||||
<p className="font-medium">{viaje.kmInicio?.toLocaleString() || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Km fin:</span>
|
||||
<p className="font-medium">{viaje.kmFin?.toLocaleString() || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Km recorridos:</span>
|
||||
<p className="font-medium">
|
||||
{viaje.kmInicio && viaje.kmFin
|
||||
? (viaje.kmFin - viaje.kmInicio).toLocaleString()
|
||||
: '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Costos */}
|
||||
<div className="rounded-lg bg-gray-50 p-4">
|
||||
<h3 className="mb-2 font-medium text-gray-900">Costos e Ingresos</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Combustible:</span>
|
||||
<p className="font-medium">{formatCurrency(viaje.costoCombustible)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Peajes:</span>
|
||||
<p className="font-medium">{formatCurrency(viaje.costoPeajes)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Viaticos:</span>
|
||||
<p className="font-medium">{formatCurrency(viaje.costoViaticos)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Otros:</span>
|
||||
<p className="font-medium">{formatCurrency(viaje.costoOtros)}</p>
|
||||
</div>
|
||||
<div className="border-t pt-2">
|
||||
<span className="text-gray-500">Costo total:</span>
|
||||
<p className="font-semibold text-red-600">{formatCurrency(viaje.costoTotal)}</p>
|
||||
</div>
|
||||
<div className="border-t pt-2">
|
||||
<span className="text-gray-500">Ingreso total:</span>
|
||||
<p className="font-semibold text-green-600">{formatCurrency(viaje.ingresoTotal)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
{canDespachar && (
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700">Km Inicio</label>
|
||||
<input
|
||||
type="number"
|
||||
value={kmInicio}
|
||||
onChange={(e) => setKmInicio(Number(e.target.value))}
|
||||
className="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => despacharMutation.mutate()}
|
||||
disabled={despacharMutation.isPending}
|
||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-white hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{despacharMutation.isPending ? 'Despachando...' : 'Despachar'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canIniciarTransito && (
|
||||
<button
|
||||
onClick={() => iniciarTransitoMutation.mutate()}
|
||||
disabled={iniciarTransitoMutation.isPending}
|
||||
className="w-full rounded-lg bg-yellow-600 px-4 py-2 text-white hover:bg-yellow-700 disabled:opacity-50"
|
||||
>
|
||||
{iniciarTransitoMutation.isPending ? 'Iniciando...' : 'Iniciar Transito'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canCerrar && (
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700">Km Fin</label>
|
||||
<input
|
||||
type="number"
|
||||
value={kmFin}
|
||||
onChange={(e) => setKmFin(Number(e.target.value))}
|
||||
className="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => cerrarMutation.mutate()}
|
||||
disabled={cerrarMutation.isPending}
|
||||
className="rounded-lg bg-teal-600 px-4 py-2 text-white hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{cerrarMutation.isPending ? 'Cerrando...' : 'Cerrar Viaje'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canCancelar && (
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700">Motivo</label>
|
||||
<input
|
||||
type="text"
|
||||
value={motivoCancelacion}
|
||||
onChange={(e) => setMotivoCancelacion(e.target.value)}
|
||||
placeholder="Motivo de cancelacion"
|
||||
className="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => cancelarMutation.mutate()}
|
||||
disabled={cancelarMutation.isPending || !motivoCancelacion}
|
||||
className="rounded-lg bg-red-600 px-4 py-2 text-white hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{cancelarMutation.isPending ? 'Cancelando...' : 'Cancelar'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 flex justify-end border-t pt-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
259
src/features/viajes/components/ViajeForm.tsx
Normal file
259
src/features/viajes/components/ViajeForm.tsx
Normal file
@ -0,0 +1,259 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import type { Viaje, CreateViajeDto, UpdateViajeDto } from '../types';
|
||||
|
||||
const viajeSchema = z.object({
|
||||
unidadId: z.string().min(1, 'Unidad requerida'),
|
||||
operadorId: z.string().min(1, 'Operador requerido'),
|
||||
remolqueId: z.string().optional(),
|
||||
clienteId: z.string().optional(),
|
||||
origenPrincipal: z.string().optional(),
|
||||
origenCiudad: z.string().optional(),
|
||||
destinoPrincipal: z.string().optional(),
|
||||
destinoCiudad: z.string().optional(),
|
||||
fechaSalidaProgramada: z.string().optional(),
|
||||
fechaLlegadaProgramada: z.string().optional(),
|
||||
distanciaEstimadaKm: z.number().optional(),
|
||||
tiempoEstimadoHoras: z.number().optional(),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof viajeSchema>;
|
||||
|
||||
interface ViajeFormProps {
|
||||
viaje?: Viaje;
|
||||
onSubmit: (data: CreateViajeDto | UpdateViajeDto) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function ViajeForm({ viaje, onSubmit, onCancel, isLoading }: ViajeFormProps) {
|
||||
const isEditing = !!viaje;
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(viajeSchema),
|
||||
defaultValues: viaje
|
||||
? {
|
||||
unidadId: viaje.unidadId,
|
||||
operadorId: viaje.operadorId,
|
||||
remolqueId: viaje.remolqueId || '',
|
||||
clienteId: viaje.clienteId || '',
|
||||
origenPrincipal: viaje.origenPrincipal || '',
|
||||
origenCiudad: viaje.origenCiudad || '',
|
||||
destinoPrincipal: viaje.destinoPrincipal || '',
|
||||
destinoCiudad: viaje.destinoCiudad || '',
|
||||
fechaSalidaProgramada: viaje.fechaSalidaProgramada?.slice(0, 16) || '',
|
||||
fechaLlegadaProgramada: viaje.fechaLlegadaProgramada?.slice(0, 16) || '',
|
||||
distanciaEstimadaKm: viaje.distanciaEstimadaKm,
|
||||
tiempoEstimadoHoras: viaje.tiempoEstimadoHoras,
|
||||
}
|
||||
: {
|
||||
unidadId: '',
|
||||
operadorId: '',
|
||||
remolqueId: '',
|
||||
clienteId: '',
|
||||
origenPrincipal: '',
|
||||
origenCiudad: '',
|
||||
destinoPrincipal: '',
|
||||
destinoCiudad: '',
|
||||
fechaSalidaProgramada: '',
|
||||
fechaLlegadaProgramada: '',
|
||||
},
|
||||
});
|
||||
|
||||
const handleFormSubmit = async (data: FormData) => {
|
||||
const cleanData: CreateViajeDto | UpdateViajeDto = {
|
||||
unidadId: data.unidadId,
|
||||
operadorId: data.operadorId,
|
||||
...(data.remolqueId && { remolqueId: data.remolqueId }),
|
||||
...(data.clienteId && { clienteId: data.clienteId }),
|
||||
...(data.origenPrincipal && { origenPrincipal: data.origenPrincipal }),
|
||||
...(data.origenCiudad && { origenCiudad: data.origenCiudad }),
|
||||
...(data.destinoPrincipal && { destinoPrincipal: data.destinoPrincipal }),
|
||||
...(data.destinoCiudad && { destinoCiudad: data.destinoCiudad }),
|
||||
...(data.fechaSalidaProgramada && { fechaSalidaProgramada: data.fechaSalidaProgramada }),
|
||||
...(data.fechaLlegadaProgramada && { fechaLlegadaProgramada: data.fechaLlegadaProgramada }),
|
||||
...(data.distanciaEstimadaKm && { distanciaEstimadaKm: data.distanciaEstimadaKm }),
|
||||
...(data.tiempoEstimadoHoras && { tiempoEstimadoHoras: data.tiempoEstimadoHoras }),
|
||||
};
|
||||
await onSubmit(cleanData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
|
||||
{/* Asignacion */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Asignacion</h3>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Unidad *
|
||||
</label>
|
||||
<input
|
||||
{...register('unidadId')}
|
||||
type="text"
|
||||
placeholder="ID de la unidad"
|
||||
className="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
{errors.unidadId && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.unidadId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Operador *
|
||||
</label>
|
||||
<input
|
||||
{...register('operadorId')}
|
||||
type="text"
|
||||
placeholder="ID del operador"
|
||||
className="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
{errors.operadorId && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.operadorId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Remolque (opcional)
|
||||
</label>
|
||||
<input
|
||||
{...register('remolqueId')}
|
||||
type="text"
|
||||
placeholder="ID del remolque"
|
||||
className="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ruta */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Ruta</h3>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Origen
|
||||
</label>
|
||||
<input
|
||||
{...register('origenPrincipal')}
|
||||
type="text"
|
||||
placeholder="Direccion de origen"
|
||||
className="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Ciudad Origen
|
||||
</label>
|
||||
<input
|
||||
{...register('origenCiudad')}
|
||||
type="text"
|
||||
placeholder="Ciudad"
|
||||
className="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Destino
|
||||
</label>
|
||||
<input
|
||||
{...register('destinoPrincipal')}
|
||||
type="text"
|
||||
placeholder="Direccion de destino"
|
||||
className="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Ciudad Destino
|
||||
</label>
|
||||
<input
|
||||
{...register('destinoCiudad')}
|
||||
type="text"
|
||||
placeholder="Ciudad"
|
||||
className="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fechas */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Programacion</h3>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Fecha/Hora Salida
|
||||
</label>
|
||||
<input
|
||||
{...register('fechaSalidaProgramada')}
|
||||
type="datetime-local"
|
||||
className="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Fecha/Hora Llegada
|
||||
</label>
|
||||
<input
|
||||
{...register('fechaLlegadaProgramada')}
|
||||
type="datetime-local"
|
||||
className="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Distancia (km)
|
||||
</label>
|
||||
<input
|
||||
{...register('distanciaEstimadaKm', { valueAsNumber: true })}
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
className="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Tiempo estimado (hrs)
|
||||
</label>
|
||||
<input
|
||||
{...register('tiempoEstimadoHoras', { valueAsNumber: true })}
|
||||
type="number"
|
||||
step="0.5"
|
||||
placeholder="0.0"
|
||||
className="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 border-t pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-white hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Guardando...' : isEditing ? 'Guardar cambios' : 'Crear viaje'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
37
src/features/viajes/components/ViajeStatusBadge.tsx
Normal file
37
src/features/viajes/components/ViajeStatusBadge.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { EstadoViaje } from '../types';
|
||||
|
||||
interface ViajeStatusBadgeProps {
|
||||
estado: EstadoViaje;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const estadoConfig: Record<EstadoViaje, { label: string; color: string }> = {
|
||||
[EstadoViaje.BORRADOR]: { label: 'Borrador', color: 'bg-gray-100 text-gray-800' },
|
||||
[EstadoViaje.PLANEADO]: { label: 'Planeado', color: 'bg-blue-100 text-blue-800' },
|
||||
[EstadoViaje.DESPACHADO]: { label: 'Despachado', color: 'bg-indigo-100 text-indigo-800' },
|
||||
[EstadoViaje.EN_TRANSITO]: { label: 'En Tránsito', color: 'bg-yellow-100 text-yellow-800' },
|
||||
[EstadoViaje.EN_DESTINO]: { label: 'En Destino', color: 'bg-orange-100 text-orange-800' },
|
||||
[EstadoViaje.ENTREGADO]: { label: 'Entregado', color: 'bg-green-100 text-green-800' },
|
||||
[EstadoViaje.CERRADO]: { label: 'Cerrado', color: 'bg-teal-100 text-teal-800' },
|
||||
[EstadoViaje.FACTURADO]: { label: 'Facturado', color: 'bg-purple-100 text-purple-800' },
|
||||
[EstadoViaje.COBRADO]: { label: 'Cobrado', color: 'bg-emerald-100 text-emerald-800' },
|
||||
[EstadoViaje.CANCELADO]: { label: 'Cancelado', color: 'bg-red-100 text-red-800' },
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-2 py-0.5 text-xs',
|
||||
md: 'px-2.5 py-1 text-sm',
|
||||
lg: 'px-3 py-1.5 text-base',
|
||||
};
|
||||
|
||||
export function ViajeStatusBadge({ estado, size = 'md' }: ViajeStatusBadgeProps) {
|
||||
const config = estadoConfig[estado] || { label: estado, color: 'bg-gray-100 text-gray-800' };
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full font-medium ${config.color} ${sizeClasses[size]}`}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
186
src/features/viajes/components/ViajesList.tsx
Normal file
186
src/features/viajes/components/ViajesList.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { viajesApi } from '../api/viajes.api';
|
||||
import { ViajeStatusBadge } from './ViajeStatusBadge';
|
||||
import type { Viaje, ViajeFilters, EstadoViaje } from '../types';
|
||||
|
||||
interface ViajesListProps {
|
||||
onSelect?: (viaje: Viaje) => void;
|
||||
filters?: ViajeFilters;
|
||||
}
|
||||
|
||||
export function ViajesList({ onSelect, filters: externalFilters }: ViajesListProps) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [estadoFilter, setEstadoFilter] = useState<EstadoViaje | ''>('');
|
||||
const limit = 10;
|
||||
|
||||
const filters: ViajeFilters = {
|
||||
...externalFilters,
|
||||
estado: estadoFilter || undefined,
|
||||
limit,
|
||||
offset: (page - 1) * limit,
|
||||
};
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['viajes', filters],
|
||||
queryFn: () => viajesApi.getAll(filters),
|
||||
});
|
||||
|
||||
const viajes = data?.data || [];
|
||||
const total = data?.total || 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return '-';
|
||||
return new Date(dateString).toLocaleDateString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-lg bg-red-50 p-4 text-red-700">
|
||||
Error al cargar viajes: {(error as Error).message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-4">
|
||||
<select
|
||||
value={estadoFilter}
|
||||
onChange={(e) => setEstadoFilter(e.target.value as EstadoViaje | '')}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">Todos los estados</option>
|
||||
<option value="BORRADOR">Borrador</option>
|
||||
<option value="PLANEADO">Planeado</option>
|
||||
<option value="DESPACHADO">Despachado</option>
|
||||
<option value="EN_TRANSITO">En Tránsito</option>
|
||||
<option value="EN_DESTINO">En Destino</option>
|
||||
<option value="ENTREGADO">Entregado</option>
|
||||
<option value="CERRADO">Cerrado</option>
|
||||
<option value="FACTURADO">Facturado</option>
|
||||
<option value="CANCELADO">Cancelado</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Viaje
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Ruta
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Unidad / Operador
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Fecha Salida
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Estado
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Costo
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
|
||||
Cargando viajes...
|
||||
</td>
|
||||
</tr>
|
||||
) : viajes.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
|
||||
No hay viajes para mostrar
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
viajes.map((viaje) => (
|
||||
<tr
|
||||
key={viaje.id}
|
||||
onClick={() => onSelect?.(viaje)}
|
||||
className="cursor-pointer hover:bg-gray-50"
|
||||
>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<div className="font-medium text-gray-900">{viaje.codigo}</div>
|
||||
{viaje.numeroViaje && (
|
||||
<div className="text-sm text-gray-500">{viaje.numeroViaje}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-sm text-gray-900">
|
||||
{viaje.origenCiudad || viaje.origenPrincipal || '-'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
→ {viaje.destinoCiudad || viaje.destinoPrincipal || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
<div>U: {viaje.unidadId?.slice(0, 8)}...</div>
|
||||
<div>O: {viaje.operadorId?.slice(0, 8)}...</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
|
||||
{formatDate(viaje.fechaSalidaProgramada || viaje.fechaProgramadaSalida)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<ViajeStatusBadge estado={viaje.estado} size="sm" />
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-right text-sm">
|
||||
<div className="font-medium text-gray-900">
|
||||
${viaje.costoTotal.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</div>
|
||||
{viaje.ingresoTotal > 0 && (
|
||||
<div className="text-green-600">
|
||||
+${viaje.ingresoTotal.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<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}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||
>
|
||||
Siguiente
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
src/features/viajes/components/index.ts
Normal file
4
src/features/viajes/components/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { ViajeStatusBadge } from './ViajeStatusBadge';
|
||||
export { ViajesList } from './ViajesList';
|
||||
export { ViajeForm } from './ViajeForm';
|
||||
export { ViajeDetail } from './ViajeDetail';
|
||||
11
src/features/viajes/index.ts
Normal file
11
src/features/viajes/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
// Types
|
||||
export * from './types';
|
||||
|
||||
// API
|
||||
export { viajesApi } from './api/viajes.api';
|
||||
|
||||
// Components
|
||||
export { ViajeStatusBadge } from './components/ViajeStatusBadge';
|
||||
export { ViajesList } from './components/ViajesList';
|
||||
export { ViajeForm } from './components/ViajeForm';
|
||||
export { ViajeDetail } from './components/ViajeDetail';
|
||||
135
src/features/viajes/types/index.ts
Normal file
135
src/features/viajes/types/index.ts
Normal file
@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Viajes (Trips) Types
|
||||
*/
|
||||
|
||||
export enum EstadoViaje {
|
||||
BORRADOR = 'BORRADOR',
|
||||
PLANEADO = 'PLANEADO',
|
||||
DESPACHADO = 'DESPACHADO',
|
||||
EN_TRANSITO = 'EN_TRANSITO',
|
||||
EN_DESTINO = 'EN_DESTINO',
|
||||
ENTREGADO = 'ENTREGADO',
|
||||
CERRADO = 'CERRADO',
|
||||
FACTURADO = 'FACTURADO',
|
||||
COBRADO = 'COBRADO',
|
||||
CANCELADO = 'CANCELADO',
|
||||
}
|
||||
|
||||
export interface InfoSello {
|
||||
numero: string;
|
||||
tipo: 'PLASTICO' | 'METALICO' | 'ELECTRONICO' | 'CANDADO';
|
||||
colocadoPor?: string;
|
||||
foto?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface ParadaViaje {
|
||||
id: string;
|
||||
viajeId: string;
|
||||
secuencia: number;
|
||||
tipo: 'RECOLECCION' | 'ENTREGA' | 'PARADA_TECNICA';
|
||||
direccion: string;
|
||||
ciudad?: string;
|
||||
codigoPostal?: string;
|
||||
latitud?: number;
|
||||
longitud?: number;
|
||||
contactoNombre?: string;
|
||||
contactoTelefono?: string;
|
||||
horaEstimadaLlegada?: string;
|
||||
horaRealLlegada?: string;
|
||||
horaEstimadaSalida?: string;
|
||||
horaRealSalida?: string;
|
||||
estado: 'PENDIENTE' | 'EN_PROGRESO' | 'COMPLETADA' | 'OMITIDA';
|
||||
instrucciones?: string;
|
||||
evidencias?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface Viaje {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
codigo: string;
|
||||
numeroViaje?: string;
|
||||
clienteId?: string;
|
||||
unidadId: string;
|
||||
remolqueId?: string;
|
||||
operadorId: string;
|
||||
origenPrincipal?: string;
|
||||
origenCiudad?: string;
|
||||
destinoPrincipal?: string;
|
||||
destinoCiudad?: string;
|
||||
distanciaEstimadaKm?: number;
|
||||
tiempoEstimadoHoras?: number;
|
||||
fechaSalidaProgramada?: string;
|
||||
fechaProgramadaSalida?: string;
|
||||
fechaLlegadaProgramada?: string;
|
||||
fechaSalidaReal?: string;
|
||||
fechaRealSalida?: string;
|
||||
fechaLlegadaReal?: string;
|
||||
fechaRealLlegada?: string;
|
||||
kmInicio?: number;
|
||||
kmFin?: number;
|
||||
estado: EstadoViaje;
|
||||
checklistCompletado: boolean;
|
||||
checklistFecha?: string;
|
||||
checklistObservaciones?: string;
|
||||
sellosSalida?: InfoSello[];
|
||||
sellosLlegada?: InfoSello[];
|
||||
costoCombustible: number;
|
||||
costoPeajes: number;
|
||||
costoViaticos: number;
|
||||
costoOtros: number;
|
||||
costoTotal: number;
|
||||
ingresoTotal: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
paradas?: ParadaViaje[];
|
||||
}
|
||||
|
||||
export interface ViajeFilters {
|
||||
search?: string;
|
||||
estado?: EstadoViaje;
|
||||
estados?: EstadoViaje[];
|
||||
unidadId?: string;
|
||||
operadorId?: string;
|
||||
clienteId?: string;
|
||||
fechaDesde?: string;
|
||||
fechaHasta?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface ViajesResponse {
|
||||
data: Viaje[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface CreateViajeDto {
|
||||
unidadId: string;
|
||||
operadorId: string;
|
||||
remolqueId?: string;
|
||||
clienteId?: string;
|
||||
origenPrincipal?: string;
|
||||
origenCiudad?: string;
|
||||
destinoPrincipal?: string;
|
||||
destinoCiudad?: string;
|
||||
fechaSalidaProgramada?: string;
|
||||
fechaLlegadaProgramada?: string;
|
||||
distanciaEstimadaKm?: number;
|
||||
tiempoEstimadoHoras?: number;
|
||||
}
|
||||
|
||||
export interface UpdateViajeDto extends Partial<CreateViajeDto> {
|
||||
estado?: EstadoViaje;
|
||||
kmInicio?: number;
|
||||
kmFin?: number;
|
||||
checklistCompletado?: boolean;
|
||||
checklistObservaciones?: string;
|
||||
}
|
||||
|
||||
export interface DatosEntrega {
|
||||
horaEntrega?: string;
|
||||
firmaReceptor?: string;
|
||||
nombreReceptor?: string;
|
||||
comentarios?: string;
|
||||
evidenciasFotos?: string[];
|
||||
}
|
||||
103
src/index.css
Normal file
103
src/index.css
Normal file
@ -0,0 +1,103 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--primary-50: #f0f9ff;
|
||||
--primary-100: #e0f2fe;
|
||||
--primary-200: #bae6fd;
|
||||
--primary-300: #7dd3fc;
|
||||
--primary-400: #38bdf8;
|
||||
--primary-500: #0ea5e9;
|
||||
--primary-600: #0284c7;
|
||||
--primary-700: #0369a1;
|
||||
--primary-800: #075985;
|
||||
--primary-900: #0c4a6e;
|
||||
--primary-950: #082f49;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
|
||||
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a1a1a1;
|
||||
}
|
||||
|
||||
/* Primary color utilities */
|
||||
.text-primary-500 {
|
||||
color: var(--primary-500);
|
||||
}
|
||||
|
||||
.text-primary-600 {
|
||||
color: var(--primary-600);
|
||||
}
|
||||
|
||||
.text-primary-700 {
|
||||
color: var(--primary-700);
|
||||
}
|
||||
|
||||
.text-primary-800 {
|
||||
color: var(--primary-800);
|
||||
}
|
||||
|
||||
.bg-primary-500 {
|
||||
background-color: var(--primary-500);
|
||||
}
|
||||
|
||||
.bg-primary-600 {
|
||||
background-color: var(--primary-600);
|
||||
}
|
||||
|
||||
.bg-primary-700 {
|
||||
background-color: var(--primary-700);
|
||||
}
|
||||
|
||||
.border-primary-500 {
|
||||
border-color: var(--primary-500);
|
||||
}
|
||||
|
||||
.hover\:bg-primary-700:hover {
|
||||
background-color: var(--primary-700);
|
||||
}
|
||||
|
||||
.hover\:text-primary-800:hover {
|
||||
color: var(--primary-800);
|
||||
}
|
||||
|
||||
.focus\:border-primary-500:focus {
|
||||
border-color: var(--primary-500);
|
||||
}
|
||||
|
||||
/* Line clamp utility */
|
||||
.line-clamp-1 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
26
src/main.tsx
Normal file
26
src/main.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60, // 1 minute
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
59
src/pages/FlotaPage.tsx
Normal file
59
src/pages/FlotaPage.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { useState } from 'react';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { UnidadesList, OperadoresList } from '../features/flota';
|
||||
|
||||
type TabType = 'unidades' | 'operadores';
|
||||
|
||||
function FlotaListPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('unidades');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Gestión de Flota</h1>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex gap-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('unidades')}
|
||||
className={`border-b-2 pb-4 text-sm font-medium ${
|
||||
activeTab === 'unidades'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Unidades
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('operadores')}
|
||||
className={`border-b-2 pb-4 text-sm font-medium ${
|
||||
activeTab === 'operadores'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Operadores
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{activeTab === 'unidades' && (
|
||||
<UnidadesList onSelect={(unidad) => console.log('Selected unidad:', unidad)} />
|
||||
)}
|
||||
{activeTab === 'operadores' && (
|
||||
<OperadoresList onSelect={(operador) => console.log('Selected operador:', operador)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FlotaPage() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<FlotaListPage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
72
src/pages/OrdenesTransportePage.tsx
Normal file
72
src/pages/OrdenesTransportePage.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
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';
|
||||
|
||||
function OTListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedOT, setSelectedOT] = useState<OrdenTransporte | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Órdenes 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"
|
||||
>
|
||||
Nueva Orden
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<OTList onSelect={setSelectedOT} />
|
||||
|
||||
{/* Detail Modal */}
|
||||
{selectedOT && (
|
||||
<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">
|
||||
<OTDetail
|
||||
orden={selectedOT}
|
||||
onClose={() => setSelectedOT(null)}
|
||||
onEdit={() => {
|
||||
setSelectedOT(null);
|
||||
navigate(`/ordenes-transporte/${selectedOT.id}/editar`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OTNuevaPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Nueva Orden de Transporte</h1>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<OTForm
|
||||
onSuccess={() => navigate('/ordenes-transporte')}
|
||||
onCancel={() => navigate('/ordenes-transporte')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrdenesTransportePage() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<OTListPage />} />
|
||||
<Route path="nueva" element={<OTNuevaPage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
70
src/pages/TrackingPage.tsx
Normal file
70
src/pages/TrackingPage.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import { useState } from 'react';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { TrackingMap, EventosList, GeocercasList } from '../features/tracking';
|
||||
|
||||
type TabType = 'mapa' | 'eventos' | 'geocercas';
|
||||
|
||||
function TrackingMainPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('mapa');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Tracking en Tiempo Real</h1>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex gap-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('mapa')}
|
||||
className={`border-b-2 pb-4 text-sm font-medium ${
|
||||
activeTab === 'mapa'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Mapa
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('eventos')}
|
||||
className={`border-b-2 pb-4 text-sm font-medium ${
|
||||
activeTab === 'eventos'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Eventos
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('geocercas')}
|
||||
className={`border-b-2 pb-4 text-sm font-medium ${
|
||||
activeTab === 'geocercas'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Geocercas
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{activeTab === 'mapa' && (
|
||||
<div className="h-[600px]">
|
||||
<TrackingMap refreshInterval={30000} />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'eventos' && <EventosList />}
|
||||
{activeTab === 'geocercas' && <GeocercasList />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TrackingPage() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<TrackingMainPage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
72
src/pages/ViajesPage.tsx
Normal file
72
src/pages/ViajesPage.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { useState } from 'react';
|
||||
import { Routes, Route, useNavigate } from 'react-router-dom';
|
||||
import { ViajesList, ViajeForm, ViajeDetail } from '../features/viajes';
|
||||
import type { Viaje } from '../features/viajes';
|
||||
|
||||
function ViajesListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedViaje, setSelectedViaje] = useState<Viaje | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Viajes</h1>
|
||||
<button
|
||||
onClick={() => navigate('/viajes/nuevo')}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-white hover:bg-primary-700"
|
||||
>
|
||||
Nuevo Viaje
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ViajesList onSelect={setSelectedViaje} />
|
||||
|
||||
{/* Detail Modal */}
|
||||
{selectedViaje && (
|
||||
<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={() => setSelectedViaje(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">
|
||||
<ViajeDetail
|
||||
viaje={selectedViaje}
|
||||
onClose={() => setSelectedViaje(null)}
|
||||
onEdit={() => {
|
||||
setSelectedViaje(null);
|
||||
navigate(`/viajes/${selectedViaje.id}/editar`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ViajesNuevoPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Nuevo Viaje</h1>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<ViajeForm
|
||||
onSuccess={() => navigate('/viajes')}
|
||||
onCancel={() => navigate('/viajes')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ViajesPage() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<ViajesListPage />} />
|
||||
<Route path="nuevo" element={<ViajesNuevoPage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
4
src/pages/index.ts
Normal file
4
src/pages/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { default as ViajesPage } from './ViajesPage';
|
||||
export { default as FlotaPage } from './FlotaPage';
|
||||
export { default as TrackingPage } from './TrackingPage';
|
||||
export { default as OrdenesTransportePage } from './OrdenesTransportePage';
|
||||
48
src/services/api/axios-instance.ts
Normal file
48
src/services/api/axios-instance.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3014';
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor for adding auth token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
const tenantId = localStorage.getItem('tenantId');
|
||||
const userId = localStorage.getItem('userId');
|
||||
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
if (tenantId) {
|
||||
config.headers['x-tenant-id'] = tenantId;
|
||||
}
|
||||
if (userId) {
|
||||
config.headers['x-user-id'] = userId;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor for handling errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
9
src/vite-env.d.ts
vendored
Normal file
9
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@ -7,6 +7,19 @@ export default {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Primary color (aliased to transport)
|
||||
primary: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
200: '#bae6fd',
|
||||
300: '#7dd3fc',
|
||||
400: '#38bdf8',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
800: '#075985',
|
||||
900: '#0c4a6e',
|
||||
},
|
||||
// Colores del giro transporte
|
||||
transport: {
|
||||
50: '#f0f9ff',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user