From f0a09fea29cc4c239246dcb3b026f668a3514766 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Mon, 26 Jan 2026 02:12:38 -0600 Subject: [PATCH] [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 --- index.html | 17 + postcss.config.js | 6 + src/App.tsx | 65 +++ src/features/flota/api/operadores.api.ts | 76 +++ src/features/flota/api/unidades.api.ts | 84 ++++ .../flota/components/OperadorStatusBadge.tsx | 36 ++ .../flota/components/OperadoresList.tsx | 192 ++++++++ .../flota/components/UnidadStatusBadge.tsx | 33 ++ .../flota/components/UnidadesList.tsx | 171 +++++++ src/features/flota/components/index.ts | 4 + src/features/flota/index.ts | 12 + src/features/flota/types/index.ts | 216 +++++++++ .../api/ordenes-transporte.api.ts | 97 ++++ .../components/OTDetail.tsx | 255 ++++++++++ .../ordenes-transporte/components/OTForm.tsx | 455 ++++++++++++++++++ .../ordenes-transporte/components/OTList.tsx | 213 ++++++++ .../components/OTStatusBadge.tsx | 35 ++ .../ordenes-transporte/components/index.ts | 4 + src/features/ordenes-transporte/index.ts | 11 + .../ordenes-transporte/types/index.ts | 179 +++++++ src/features/tracking/api/tracking.api.ts | 129 +++++ .../tracking/components/EventosList.tsx | 184 +++++++ .../tracking/components/GeocercasList.tsx | 241 ++++++++++ .../tracking/components/TrackingMap.tsx | 160 ++++++ src/features/tracking/components/index.ts | 3 + src/features/tracking/index.ts | 10 + src/features/tracking/types/index.ts | 163 +++++++ src/features/viajes/api/viajes.api.ts | 100 ++++ .../viajes/components/ViajeDetail.tsx | 266 ++++++++++ src/features/viajes/components/ViajeForm.tsx | 259 ++++++++++ .../viajes/components/ViajeStatusBadge.tsx | 37 ++ src/features/viajes/components/ViajesList.tsx | 186 +++++++ src/features/viajes/components/index.ts | 4 + src/features/viajes/index.ts | 11 + src/features/viajes/types/index.ts | 135 ++++++ src/index.css | 103 ++++ src/main.tsx | 26 + src/pages/FlotaPage.tsx | 59 +++ src/pages/OrdenesTransportePage.tsx | 72 +++ src/pages/TrackingPage.tsx | 70 +++ src/pages/ViajesPage.tsx | 72 +++ src/pages/index.ts | 4 + src/services/api/axios-instance.ts | 48 ++ src/vite-env.d.ts | 9 + tailwind.config.js | 13 + 45 files changed, 4525 insertions(+) create mode 100644 index.html create mode 100644 postcss.config.js create mode 100644 src/App.tsx create mode 100644 src/features/flota/api/operadores.api.ts create mode 100644 src/features/flota/api/unidades.api.ts create mode 100644 src/features/flota/components/OperadorStatusBadge.tsx create mode 100644 src/features/flota/components/OperadoresList.tsx create mode 100644 src/features/flota/components/UnidadStatusBadge.tsx create mode 100644 src/features/flota/components/UnidadesList.tsx create mode 100644 src/features/flota/components/index.ts create mode 100644 src/features/flota/index.ts create mode 100644 src/features/flota/types/index.ts create mode 100644 src/features/ordenes-transporte/api/ordenes-transporte.api.ts create mode 100644 src/features/ordenes-transporte/components/OTDetail.tsx create mode 100644 src/features/ordenes-transporte/components/OTForm.tsx create mode 100644 src/features/ordenes-transporte/components/OTList.tsx create mode 100644 src/features/ordenes-transporte/components/OTStatusBadge.tsx create mode 100644 src/features/ordenes-transporte/components/index.ts create mode 100644 src/features/ordenes-transporte/index.ts create mode 100644 src/features/ordenes-transporte/types/index.ts create mode 100644 src/features/tracking/api/tracking.api.ts create mode 100644 src/features/tracking/components/EventosList.tsx create mode 100644 src/features/tracking/components/GeocercasList.tsx create mode 100644 src/features/tracking/components/TrackingMap.tsx create mode 100644 src/features/tracking/components/index.ts create mode 100644 src/features/tracking/index.ts create mode 100644 src/features/tracking/types/index.ts create mode 100644 src/features/viajes/api/viajes.api.ts create mode 100644 src/features/viajes/components/ViajeDetail.tsx create mode 100644 src/features/viajes/components/ViajeForm.tsx create mode 100644 src/features/viajes/components/ViajeStatusBadge.tsx create mode 100644 src/features/viajes/components/ViajesList.tsx create mode 100644 src/features/viajes/components/index.ts create mode 100644 src/features/viajes/index.ts create mode 100644 src/features/viajes/types/index.ts create mode 100644 src/index.css create mode 100644 src/main.tsx create mode 100644 src/pages/FlotaPage.tsx create mode 100644 src/pages/OrdenesTransportePage.tsx create mode 100644 src/pages/TrackingPage.tsx create mode 100644 src/pages/ViajesPage.tsx create mode 100644 src/pages/index.ts create mode 100644 src/services/api/axios-instance.ts create mode 100644 src/vite-env.d.ts diff --git a/index.html b/index.html new file mode 100644 index 0000000..09f75b8 --- /dev/null +++ b/index.html @@ -0,0 +1,17 @@ + + + + + + + ERP Transportistas + + + +
+ + + diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..faa7725 --- /dev/null +++ b/src/App.tsx @@ -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 ( +
+
Cargando...
+
+ ); +} + +// Main Layout +function MainLayout({ children }: { children: React.ReactNode }) { + return ( +
+ {/* Header */} +
+
+
+

ERP Transportistas

+ +
+
+ Usuario +
+
+
+ + {/* Main Content */} +
+ {children} +
+
+ ); +} + +function App() { + return ( + + }> + + } /> + } /> + } /> + } /> + } /> + + + + ); +} + +export default App; diff --git a/src/features/flota/api/operadores.api.ts b/src/features/flota/api/operadores.api.ts new file mode 100644 index 0000000..c1d1469 --- /dev/null +++ b/src/features/flota/api/operadores.api.ts @@ -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 => { + 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(`${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 => { + 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; + }, +}; diff --git a/src/features/flota/api/unidades.api.ts b/src/features/flota/api/unidades.api.ts new file mode 100644 index 0000000..eae0319 --- /dev/null +++ b/src/features/flota/api/unidades.api.ts @@ -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 => { + 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(`${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 => { + 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; + }, +}; diff --git a/src/features/flota/components/OperadorStatusBadge.tsx b/src/features/flota/components/OperadorStatusBadge.tsx new file mode 100644 index 0000000..a0d00e4 --- /dev/null +++ b/src/features/flota/components/OperadorStatusBadge.tsx @@ -0,0 +1,36 @@ +import { EstadoOperador } from '../types'; + +interface OperadorStatusBadgeProps { + estado: EstadoOperador; + size?: 'sm' | 'md' | 'lg'; +} + +const estadoConfig: Record = { + [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 ( + + {config.label} + + ); +} diff --git a/src/features/flota/components/OperadoresList.tsx b/src/features/flota/components/OperadoresList.tsx new file mode 100644 index 0000000..cee395f --- /dev/null +++ b/src/features/flota/components/OperadoresList.tsx @@ -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(''); + const [licenciaFilter, setLicenciaFilter] = useState(''); + 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 ( +
+ Error al cargar operadores: {(error as Error).message} +
+ ); + } + + return ( +
+ {/* Filters */} +
+ + +
+ + {/* Table */} +
+ + + + + + + + + + + + {isLoading ? ( + + + + ) : operadores.length === 0 ? ( + + + + ) : ( + operadores.map((operador) => ( + onSelect?.(operador)} + className="cursor-pointer hover:bg-gray-50" + > + + + + + + + )) + )} + +
+ Operador + + Licencia + + Vigencia + + Estado + + Viajes +
+ Cargando operadores... +
+ No hay operadores para mostrar +
+
+ {operador.nombre} {operador.apellidoPaterno} {operador.apellidoMaterno} +
+
{operador.numeroEmpleado}
+
+ {operador.tipoLicencia ? `Tipo ${operador.tipoLicencia}` : '-'} + {operador.numeroLicencia && ( +
{operador.numeroLicencia}
+ )} +
+ + {formatDate(operador.licenciaVigencia)} + + + + + {operador.totalViajes.toLocaleString()} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+
+ Mostrando {(page - 1) * limit + 1} - {Math.min(page * limit, total)} de {total} +
+
+ + +
+
+ )} +
+ ); +} diff --git a/src/features/flota/components/UnidadStatusBadge.tsx b/src/features/flota/components/UnidadStatusBadge.tsx new file mode 100644 index 0000000..f2ebce5 --- /dev/null +++ b/src/features/flota/components/UnidadStatusBadge.tsx @@ -0,0 +1,33 @@ +import { EstadoUnidad } from '../types'; + +interface UnidadStatusBadgeProps { + estado: EstadoUnidad; + size?: 'sm' | 'md' | 'lg'; +} + +const estadoConfig: Record = { + [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 ( + + {config.label} + + ); +} diff --git a/src/features/flota/components/UnidadesList.tsx b/src/features/flota/components/UnidadesList.tsx new file mode 100644 index 0000000..732d9f4 --- /dev/null +++ b/src/features/flota/components/UnidadesList.tsx @@ -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(''); + const [tipoFilter, setTipoFilter] = useState(''); + 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 ( +
+ Error al cargar unidades: {(error as Error).message} +
+ ); + } + + return ( +
+ {/* Filters */} +
+ + +
+ + {/* Table */} +
+ + + + + + + + + + + + {isLoading ? ( + + + + ) : unidades.length === 0 ? ( + + + + ) : ( + unidades.map((unidad) => ( + onSelect?.(unidad)} + className="cursor-pointer hover:bg-gray-50" + > + + + + + + + )) + )} + +
+ Unidad + + Tipo + + Placas + + Estado + + Odometro +
+ Cargando unidades... +
+ No hay unidades para mostrar +
+
{unidad.numeroEconomico}
+
+ {unidad.marca} {unidad.modelo} {unidad.anio} +
+
+ {unidad.tipo.replace(/_/g, ' ')} + + {unidad.placa || unidad.placas || '-'} + + + + {unidad.odometroActual.toLocaleString()} km +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+
+ Mostrando {(page - 1) * limit + 1} - {Math.min(page * limit, total)} de {total} +
+
+ + +
+
+ )} +
+ ); +} diff --git a/src/features/flota/components/index.ts b/src/features/flota/components/index.ts new file mode 100644 index 0000000..c10769b --- /dev/null +++ b/src/features/flota/components/index.ts @@ -0,0 +1,4 @@ +export { UnidadStatusBadge } from './UnidadStatusBadge'; +export { OperadorStatusBadge } from './OperadorStatusBadge'; +export { UnidadesList } from './UnidadesList'; +export { OperadoresList } from './OperadoresList'; diff --git a/src/features/flota/index.ts b/src/features/flota/index.ts new file mode 100644 index 0000000..66eb179 --- /dev/null +++ b/src/features/flota/index.ts @@ -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'; diff --git a/src/features/flota/types/index.ts b/src/features/flota/types/index.ts new file mode 100644 index 0000000..42414e2 --- /dev/null +++ b/src/features/flota/types/index.ts @@ -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 { + 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 { + estado?: EstadoOperador; + unidadAsignadaId?: string; +} diff --git a/src/features/ordenes-transporte/api/ordenes-transporte.api.ts b/src/features/ordenes-transporte/api/ordenes-transporte.api.ts new file mode 100644 index 0000000..0439220 --- /dev/null +++ b/src/features/ordenes-transporte/api/ordenes-transporte.api.ts @@ -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 => { + 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(`${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; + }, +}; diff --git a/src/features/ordenes-transporte/components/OTDetail.tsx b/src/features/ordenes-transporte/components/OTDetail.tsx new file mode 100644 index 0000000..4dc83be --- /dev/null +++ b/src/features/ordenes-transporte/components/OTDetail.tsx @@ -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 ( +
+ {/* Header */} +
+
+

{orden.numero}

+
+ + + {orden.modalidad} • {orden.tipoCarga?.replace(/_/g, ' ')} + +
+
+ {onClose && ( + + )} +
+ + {/* Cliente */} +
+

Cliente

+
{orden.clienteNombre}
+ {orden.referenciaCliente && ( +
Ref: {orden.referenciaCliente}
+ )} +
+ + {/* Origen y Destino */} +
+
+

Origen

+
+
{orden.origenDireccion}
+
+ {orden.origenCiudad}, {orden.origenEstado} {orden.origenCP} +
+
{orden.origenPais}
+
+
+
+

Destino

+
+
{orden.destinoDireccion}
+
+ {orden.destinoCiudad}, {orden.destinoEstado} {orden.destinoCP} +
+
{orden.destinoPais}
+
+
+
+ + {/* Fechas */} +
+
+
Recolección
+
{formatDate(orden.fechaRecoleccion)}
+
+
+
Entrega Estimada
+
{formatDate(orden.fechaEntregaEstimada)}
+
+
+
Creación
+
{formatDateTime(orden.createdAt)}
+
+
+
Última Actualización
+
{formatDateTime(orden.updatedAt)}
+
+
+ + {/* Carga */} +
+

Información de Carga

+
+ {orden.pesoBruto && ( +
+
Peso Bruto
+
{orden.pesoBruto.toLocaleString()} kg
+
+ )} + {orden.pesoNeto && ( +
+
Peso Neto
+
{orden.pesoNeto.toLocaleString()} kg
+
+ )} + {orden.volumen && ( +
+
Volumen
+
{orden.volumen.toLocaleString()} m³
+
+ )} + {orden.cantidadBultos && ( +
+
Bultos
+
{orden.cantidadBultos.toLocaleString()}
+
+ )} +
+ {orden.descripcionMercancia && ( +
+
Descripción
+
{orden.descripcionMercancia}
+
+ )} + {orden.tipoEquipo && ( +
+
Tipo de Equipo Requerido
+
{orden.tipoEquipo.replace(/_/g, ' ')}
+
+ )} +
+ + {/* Tarifa */} +
+

Tarifa

+
+
+
Tarifa Base
+
{formatCurrency(orden.tarifaBase)}
+
+
+
Tarifa Total
+
{formatCurrency(orden.tarifaTotal)}
+
+ {orden.valorMercancia && ( +
+
Valor Mercancía
+
{formatCurrency(orden.valorMercancia)}
+
+ )} +
+
+ + {/* Viaje Asignado */} + {orden.viajeId && ( +
+

Viaje Asignado

+
+ Viaje: {orden.viajeNumero || orden.viajeId} +
+
+ )} + + {/* Notas */} + {orden.notas && ( +
+

Notas

+
{orden.notas}
+
+ )} + + {/* Actions */} +
+ {canConfirm && ( + + )} + {onEdit && ( + + )} + {canCancel && ( + + )} +
+
+ ); +} diff --git a/src/features/ordenes-transporte/components/OTForm.tsx b/src/features/ordenes-transporte/components/OTForm.tsx new file mode 100644 index 0000000..aa06c0d --- /dev/null +++ b/src/features/ordenes-transporte/components/OTForm.tsx @@ -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; + +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({ + 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 ( +
+ {/* Cliente y Referencia */} +
+
+ + + {errors.clienteId && ( +

{errors.clienteId.message}

+ )} +
+
+ + +
+
+ + {/* Modalidad, Tipo de Carga, Tipo de Equipo */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + {/* Origen */} +
+ Origen +
+
+ + + {errors.origenDireccion && ( +

{errors.origenDireccion.message}

+ )} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + {/* Destino */} +
+ Destino +
+
+ + + {errors.destinoDireccion && ( +

{errors.destinoDireccion.message}

+ )} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + {/* Fechas */} +
+
+ + + {errors.fechaRecoleccion && ( +

{errors.fechaRecoleccion.message}

+ )} +
+
+ + +
+
+ + {/* Carga */} +
+ Información de Carga +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + {/* Tarifa */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + {/* Notas */} +
+ +