[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:
Adrian Flores Cortes 2026-01-26 02:12:38 -06:00
parent efc8cef4cd
commit f0a09fea29
45 changed files with 4525 additions and 0 deletions

17
index.html Normal file
View 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
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

65
src/App.tsx Normal file
View 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;

View 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;
},
};

View 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;
},
};

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

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

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

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

View File

@ -0,0 +1,4 @@
export { UnidadStatusBadge } from './UnidadStatusBadge';
export { OperadorStatusBadge } from './OperadorStatusBadge';
export { UnidadesList } from './UnidadesList';
export { OperadoresList } from './OperadoresList';

View 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';

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

View File

@ -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;
},
};

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

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

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

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

View File

@ -0,0 +1,4 @@
export { OTStatusBadge } from './OTStatusBadge';
export { OTList } from './OTList';
export { OTForm } from './OTForm';
export { OTDetail } from './OTDetail';

View 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';

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

View 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}`);
},
};

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

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

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

View File

@ -0,0 +1,3 @@
export { TrackingMap } from './TrackingMap';
export { EventosList } from './EventosList';
export { GeocercasList } from './GeocercasList';

View 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';

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

View 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;
},
};

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

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

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

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

View File

@ -0,0 +1,4 @@
export { ViajeStatusBadge } from './ViajeStatusBadge';
export { ViajesList } from './ViajesList';
export { ViajeForm } from './ViajeForm';
export { ViajeDetail } from './ViajeDetail';

View 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';

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

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

View 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
View 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
View 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';

View 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
View File

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@ -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',