[WORKSPACE] feat: Add tracking/despacho features and components
This commit is contained in:
parent
f0a09fea29
commit
1de866febe
@ -6,6 +6,7 @@ const ViajesPage = lazy(() => import('./pages/ViajesPage'));
|
|||||||
const FlotaPage = lazy(() => import('./pages/FlotaPage'));
|
const FlotaPage = lazy(() => import('./pages/FlotaPage'));
|
||||||
const TrackingPage = lazy(() => import('./pages/TrackingPage'));
|
const TrackingPage = lazy(() => import('./pages/TrackingPage'));
|
||||||
const OrdenesTransportePage = lazy(() => import('./pages/OrdenesTransportePage'));
|
const OrdenesTransportePage = lazy(() => import('./pages/OrdenesTransportePage'));
|
||||||
|
const DespachoPage = lazy(() => import('./pages/DespachoPage'));
|
||||||
|
|
||||||
// Loading fallback
|
// Loading fallback
|
||||||
function PageLoader() {
|
function PageLoader() {
|
||||||
@ -26,6 +27,7 @@ function MainLayout({ children }: { children: React.ReactNode }) {
|
|||||||
<div className="flex items-center gap-8">
|
<div className="flex items-center gap-8">
|
||||||
<h1 className="text-xl font-bold text-primary-600">ERP Transportistas</h1>
|
<h1 className="text-xl font-bold text-primary-600">ERP Transportistas</h1>
|
||||||
<nav className="flex gap-4">
|
<nav className="flex gap-4">
|
||||||
|
<a href="/despacho" className="text-gray-600 hover:text-gray-900">Despacho</a>
|
||||||
<a href="/viajes" className="text-gray-600 hover:text-gray-900">Viajes</a>
|
<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="/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="/tracking" className="text-gray-600 hover:text-gray-900">Tracking</a>
|
||||||
@ -51,7 +53,8 @@ function App() {
|
|||||||
<MainLayout>
|
<MainLayout>
|
||||||
<Suspense fallback={<PageLoader />}>
|
<Suspense fallback={<PageLoader />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/viajes" replace />} />
|
<Route path="/" element={<Navigate to="/despacho" replace />} />
|
||||||
|
<Route path="/despacho/*" element={<DespachoPage />} />
|
||||||
<Route path="/viajes/*" element={<ViajesPage />} />
|
<Route path="/viajes/*" element={<ViajesPage />} />
|
||||||
<Route path="/flota/*" element={<FlotaPage />} />
|
<Route path="/flota/*" element={<FlotaPage />} />
|
||||||
<Route path="/tracking/*" element={<TrackingPage />} />
|
<Route path="/tracking/*" element={<TrackingPage />} />
|
||||||
|
|||||||
184
src/features/despacho/api/despacho.api.ts
Normal file
184
src/features/despacho/api/despacho.api.ts
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* Despacho API
|
||||||
|
* ERP Transportistas
|
||||||
|
* Sprint S6 - TASK-007
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { api } from '@/services/api/axios-instance';
|
||||||
|
import type {
|
||||||
|
TablerosResponse,
|
||||||
|
EstadosUnidadResponse,
|
||||||
|
ViajesPendientesResponse,
|
||||||
|
SugerenciasResponse,
|
||||||
|
LogsDespachoResponse,
|
||||||
|
DespachoFilters,
|
||||||
|
ViajesFilters,
|
||||||
|
AsignarViajeDto,
|
||||||
|
ActualizarEstadoUnidadDto,
|
||||||
|
TableroDespacho,
|
||||||
|
EstadoUnidad,
|
||||||
|
ViajePendiente,
|
||||||
|
LogDespacho,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const BASE_URL = '/api/v1/despacho';
|
||||||
|
|
||||||
|
export const despachoApi = {
|
||||||
|
// ==================== Tableros ====================
|
||||||
|
|
||||||
|
// Get all dispatch boards
|
||||||
|
getTableros: async (): Promise<TablerosResponse> => {
|
||||||
|
const response = await api.get<TablerosResponse>(`${BASE_URL}/tableros`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get tablero by ID
|
||||||
|
getTableroById: async (id: string): Promise<{ data: TableroDespacho }> => {
|
||||||
|
const response = await api.get<{ data: TableroDespacho }>(`${BASE_URL}/tableros/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== Estados de Unidades ====================
|
||||||
|
|
||||||
|
// Get all unit statuses
|
||||||
|
getEstadosUnidad: async (filters?: DespachoFilters): Promise<EstadosUnidadResponse> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters?.tableroId) params.append('tableroId', filters.tableroId);
|
||||||
|
if (filters?.estado) params.append('estado', filters.estado);
|
||||||
|
if (filters?.zona) params.append('zona', filters.zona);
|
||||||
|
if (filters?.limit) params.append('limit', String(filters.limit));
|
||||||
|
if (filters?.offset) params.append('offset', String(filters.offset));
|
||||||
|
|
||||||
|
const response = await api.get<EstadosUnidadResponse>(
|
||||||
|
`${BASE_URL}/unidades?${params.toString()}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get unit status by ID
|
||||||
|
getEstadoUnidad: async (unidadId: string): Promise<{ data: EstadoUnidad }> => {
|
||||||
|
const response = await api.get<{ data: EstadoUnidad }>(
|
||||||
|
`${BASE_URL}/unidades/${unidadId}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update unit status
|
||||||
|
actualizarEstadoUnidad: async (
|
||||||
|
unidadId: string,
|
||||||
|
data: ActualizarEstadoUnidadDto
|
||||||
|
): Promise<{ data: EstadoUnidad }> => {
|
||||||
|
const response = await api.patch<{ data: EstadoUnidad }>(
|
||||||
|
`${BASE_URL}/unidades/${unidadId}/estado`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== Viajes Pendientes ====================
|
||||||
|
|
||||||
|
// Get pending trips
|
||||||
|
getViajesPendientes: async (filters?: ViajesFilters): Promise<ViajesPendientesResponse> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters?.estado) params.append('estado', filters.estado);
|
||||||
|
if (filters?.prioridad) params.append('prioridad', filters.prioridad);
|
||||||
|
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<ViajesPendientesResponse>(
|
||||||
|
`${BASE_URL}/viajes/pendientes?${params.toString()}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get trip by ID
|
||||||
|
getViaje: async (viajeId: string): Promise<{ data: ViajePendiente }> => {
|
||||||
|
const response = await api.get<{ data: ViajePendiente }>(
|
||||||
|
`${BASE_URL}/viajes/${viajeId}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== Asignacion ====================
|
||||||
|
|
||||||
|
// Get assignment suggestions for a trip
|
||||||
|
getSugerencias: async (viajeId: string): Promise<SugerenciasResponse> => {
|
||||||
|
const response = await api.get<SugerenciasResponse>(
|
||||||
|
`${BASE_URL}/sugerir/${viajeId}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Assign trip to unit/operator
|
||||||
|
asignarViaje: async (data: AsignarViajeDto): Promise<{ data: LogDespacho }> => {
|
||||||
|
const response = await api.post<{ data: LogDespacho }>(
|
||||||
|
`${BASE_URL}/asignar`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cancel trip assignment
|
||||||
|
cancelarAsignacion: async (
|
||||||
|
viajeId: string,
|
||||||
|
motivo: string
|
||||||
|
): Promise<{ data: LogDespacho }> => {
|
||||||
|
const response = await api.post<{ data: LogDespacho }>(
|
||||||
|
`${BASE_URL}/viajes/${viajeId}/cancelar`,
|
||||||
|
{ motivo }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Release unit from trip
|
||||||
|
liberarUnidad: async (
|
||||||
|
unidadId: string,
|
||||||
|
motivo?: string
|
||||||
|
): Promise<{ data: EstadoUnidad }> => {
|
||||||
|
const response = await api.post<{ data: EstadoUnidad }>(
|
||||||
|
`${BASE_URL}/unidades/${unidadId}/liberar`,
|
||||||
|
{ motivo }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== GPS Integration ====================
|
||||||
|
|
||||||
|
// Sync GPS positions to dispatch
|
||||||
|
sincronizarGps: async (tableroId: string): Promise<{ data: { sincronizadas: number } }> => {
|
||||||
|
const response = await api.post<{ data: { sincronizadas: number } }>(
|
||||||
|
`${BASE_URL}/gps/sincronizar`,
|
||||||
|
{ tableroId }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get units with GPS position
|
||||||
|
getUnidadesConGps: async (): Promise<EstadosUnidadResponse> => {
|
||||||
|
const response = await api.get<EstadosUnidadResponse>(
|
||||||
|
`${BASE_URL}/gps/unidades`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== Logs ====================
|
||||||
|
|
||||||
|
// Get dispatch logs
|
||||||
|
getLogs: async (
|
||||||
|
tableroId?: string,
|
||||||
|
viajeId?: string,
|
||||||
|
limit?: number
|
||||||
|
): Promise<LogsDespachoResponse> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (tableroId) params.append('tableroId', tableroId);
|
||||||
|
if (viajeId) params.append('viajeId', viajeId);
|
||||||
|
if (limit) params.append('limit', String(limit));
|
||||||
|
|
||||||
|
const response = await api.get<LogsDespachoResponse>(
|
||||||
|
`${BASE_URL}/logs?${params.toString()}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
227
src/features/despacho/components/AsignacionModal.tsx
Normal file
227
src/features/despacho/components/AsignacionModal.tsx
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
/**
|
||||||
|
* Asignacion Modal
|
||||||
|
* ERP Transportistas
|
||||||
|
* Sprint S6 - TASK-007
|
||||||
|
*
|
||||||
|
* Modal for assigning trips with suggestions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { ViajePendiente, SugerenciaAsignacion, TipoCertificacion } from '../types';
|
||||||
|
|
||||||
|
interface AsignacionModalProps {
|
||||||
|
viaje: ViajePendiente;
|
||||||
|
sugerencias: SugerenciaAsignacion[];
|
||||||
|
loading?: boolean;
|
||||||
|
onAsignar: (viajeId: string, unidadId: string, operadorId: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const certLabels: Record<TipoCertificacion, string> = {
|
||||||
|
LIC_FEDERAL: 'Lic. Federal',
|
||||||
|
CERT_MP: 'Cert. MP',
|
||||||
|
HAZMAT: 'HAZMAT',
|
||||||
|
REFRIGERADO: 'Refrigerado',
|
||||||
|
SOBREDIMENSIONADO: 'Sobredim.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AsignacionModal({
|
||||||
|
viaje,
|
||||||
|
sugerencias,
|
||||||
|
loading = false,
|
||||||
|
onAsignar,
|
||||||
|
onClose,
|
||||||
|
}: AsignacionModalProps) {
|
||||||
|
const [selectedSugerencia, setSelectedSugerencia] = useState<string | null>(null);
|
||||||
|
const [motivo, setMotivo] = useState('');
|
||||||
|
|
||||||
|
const handleAsignar = () => {
|
||||||
|
const sugerencia = sugerencias.find((s) => s.unidadId === selectedSugerencia);
|
||||||
|
if (sugerencia) {
|
||||||
|
onAsignar(viaje.id, sugerencia.unidadId, sugerencia.operadorId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getScoreColor = (score: number) => {
|
||||||
|
if (score >= 80) return 'text-green-600 bg-green-100';
|
||||||
|
if (score >= 60) return 'text-yellow-600 bg-yellow-100';
|
||||||
|
return 'text-red-600 bg-red-100';
|
||||||
|
};
|
||||||
|
|
||||||
|
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-hidden rounded-lg bg-white shadow-xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Asignar Viaje</h2>
|
||||||
|
<p className="mt-0.5 text-sm text-gray-500">{viaje.folio}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg p-2 text-gray-400 hover:bg-gray-200 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trip info */}
|
||||||
|
<div className="border-b border-gray-100 bg-gray-50 px-6 py-3">
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Origen:</span>
|
||||||
|
<span className="ml-2 font-medium text-gray-900">{viaje.origen}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Destino:</span>
|
||||||
|
<span className="ml-2 font-medium text-gray-900">{viaje.destino}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Fecha:</span>
|
||||||
|
<span className="ml-2 font-medium text-gray-900">
|
||||||
|
{new Date(viaje.fechaCita).toLocaleDateString('es-MX')} {viaje.horaCita}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Cliente:</span>
|
||||||
|
<span className="ml-2 font-medium text-gray-900">{viaje.clienteNombre}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{viaje.requisitos.length > 0 && (
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<span className="text-sm text-gray-500">Requisitos:</span>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{viaje.requisitos.map((req) => (
|
||||||
|
<span
|
||||||
|
key={req}
|
||||||
|
className="rounded bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800"
|
||||||
|
>
|
||||||
|
{certLabels[req]}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Suggestions list */}
|
||||||
|
<div className="max-h-80 overflow-y-auto px-6 py-4">
|
||||||
|
<h3 className="mb-3 text-sm font-medium text-gray-700">
|
||||||
|
Sugerencias de Asignación ({sugerencias.length})
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-32 items-center justify-center text-gray-500">
|
||||||
|
Calculando sugerencias...
|
||||||
|
</div>
|
||||||
|
) : sugerencias.length === 0 ? (
|
||||||
|
<div className="flex h-32 items-center justify-center text-gray-500">
|
||||||
|
No hay unidades disponibles que cumplan los requisitos
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{sugerencias.map((sug, index) => (
|
||||||
|
<li
|
||||||
|
key={sug.unidadId}
|
||||||
|
onClick={() => setSelectedSugerencia(sug.unidadId)}
|
||||||
|
className={`cursor-pointer rounded-lg border p-3 transition-colors ${
|
||||||
|
selectedSugerencia === sug.unidadId
|
||||||
|
? 'border-primary-500 bg-primary-50'
|
||||||
|
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
{/* Unit info */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-100 text-sm font-bold text-gray-600">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">{sug.numeroEconomico}</div>
|
||||||
|
<div className="text-sm text-gray-500">{sug.operadorNombre}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Score */}
|
||||||
|
<div className={`rounded-full px-3 py-1 text-sm font-bold ${getScoreColor(sug.score)}`}>
|
||||||
|
{sug.score.toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
<div className="mt-2 grid grid-cols-3 gap-2 text-xs text-gray-500">
|
||||||
|
<div>
|
||||||
|
<span className="block font-medium text-gray-700">Distancia</span>
|
||||||
|
{sug.distanciaKm.toFixed(1)} km
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="block font-medium text-gray-700">ETA</span>
|
||||||
|
{sug.tiempoEstimadoMin} min
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="block font-medium text-gray-700">Factores</span>
|
||||||
|
D:{sug.factores.distancia.toFixed(0)} C:{sug.factores.certificaciones.toFixed(0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Certifications */}
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{sug.certificacionesCumplidas.map((cert) => (
|
||||||
|
<span
|
||||||
|
key={cert}
|
||||||
|
className="rounded bg-green-100 px-1.5 py-0.5 text-xs text-green-700"
|
||||||
|
>
|
||||||
|
{certLabels[cert]}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{sug.certificacionesFaltantes.map((cert) => (
|
||||||
|
<span
|
||||||
|
key={cert}
|
||||||
|
className="rounded bg-red-100 px-1.5 py-0.5 text-xs text-red-700 line-through"
|
||||||
|
>
|
||||||
|
{certLabels[cert]}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="border-t border-gray-100 px-6 py-3">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
Notas de asignación (opcional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={motivo}
|
||||||
|
onChange={(e) => setMotivo(e.target.value)}
|
||||||
|
placeholder="Ej: Asignación urgente por solicitud del cliente"
|
||||||
|
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-end gap-3 border-t border-gray-200 bg-gray-50 px-6 py-4">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleAsignar}
|
||||||
|
disabled={!selectedSugerencia}
|
||||||
|
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Asignar Viaje
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
src/features/despacho/components/DispatchMap.tsx
Normal file
217
src/features/despacho/components/DispatchMap.tsx
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
/**
|
||||||
|
* Dispatch Map Component
|
||||||
|
* ERP Transportistas
|
||||||
|
* Sprint S6 - TASK-007
|
||||||
|
*
|
||||||
|
* Map showing units and trip origins/destinations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { EstadoUnidad, ViajePendiente, EstadoUnidadDespacho } from '../types';
|
||||||
|
|
||||||
|
interface DispatchMapProps {
|
||||||
|
unidades: EstadoUnidad[];
|
||||||
|
viajes: ViajePendiente[];
|
||||||
|
selectedUnidad?: string | null;
|
||||||
|
selectedViaje?: string | null;
|
||||||
|
onSelectUnidad: (id: string | null) => void;
|
||||||
|
onSelectViaje: (id: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status colors for units
|
||||||
|
const statusColors: Record<EstadoUnidadDespacho, string> = {
|
||||||
|
DISPONIBLE: 'bg-green-500',
|
||||||
|
EN_VIAJE: 'bg-blue-500',
|
||||||
|
EN_CARGA: 'bg-yellow-500',
|
||||||
|
EN_DESCARGA: 'bg-orange-500',
|
||||||
|
EN_MANTENIMIENTO: 'bg-red-500',
|
||||||
|
FUERA_SERVICIO: 'bg-gray-500',
|
||||||
|
EN_DESCANSO: 'bg-purple-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<EstadoUnidadDespacho, string> = {
|
||||||
|
DISPONIBLE: 'Disponible',
|
||||||
|
EN_VIAJE: 'En Viaje',
|
||||||
|
EN_CARGA: 'En Carga',
|
||||||
|
EN_DESCARGA: 'En Descarga',
|
||||||
|
EN_MANTENIMIENTO: 'Mantenimiento',
|
||||||
|
FUERA_SERVICIO: 'Fuera de Servicio',
|
||||||
|
EN_DESCANSO: 'En Descanso',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DispatchMap({
|
||||||
|
unidades,
|
||||||
|
viajes,
|
||||||
|
selectedUnidad,
|
||||||
|
selectedViaje,
|
||||||
|
onSelectUnidad,
|
||||||
|
onSelectViaje,
|
||||||
|
}: DispatchMapProps) {
|
||||||
|
// Count units by status
|
||||||
|
const statusCounts = useMemo(() => {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
unidades.forEach((u) => {
|
||||||
|
counts[u.estado] = (counts[u.estado] || 0) + 1;
|
||||||
|
});
|
||||||
|
return counts;
|
||||||
|
}, [unidades]);
|
||||||
|
|
||||||
|
// Simple coordinate transform for demo (Mexico centered)
|
||||||
|
const transformCoords = (lat: number, lng: number) => {
|
||||||
|
// Mexico bounds approximately: lat 14-33, lng -118 to -86
|
||||||
|
const x = ((lng + 118) / 32) * 100;
|
||||||
|
const y = ((33 - lat) / 19) * 100;
|
||||||
|
return { x: Math.max(0, Math.min(100, x)), y: Math.max(0, Math.min(100, y)) };
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-full w-full overflow-hidden rounded-lg border border-gray-200 bg-gray-100">
|
||||||
|
{/* Map Area */}
|
||||||
|
<div className="relative h-full w-full">
|
||||||
|
{/* Placeholder map background */}
|
||||||
|
<div className="h-full w-full bg-gradient-to-br from-blue-50 via-green-50 to-yellow-50" />
|
||||||
|
|
||||||
|
{/* Trip origins (markers) */}
|
||||||
|
{viajes
|
||||||
|
.filter((v) => v.origenCoords)
|
||||||
|
.map((viaje) => {
|
||||||
|
const coords = transformCoords(
|
||||||
|
viaje.origenCoords!.latitud,
|
||||||
|
viaje.origenCoords!.longitud
|
||||||
|
);
|
||||||
|
const isSelected = selectedViaje === viaje.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`origen-${viaje.id}`}
|
||||||
|
onClick={() => onSelectViaje(isSelected ? null : viaje.id)}
|
||||||
|
className={`absolute cursor-pointer transition-all ${
|
||||||
|
isSelected ? 'z-20 scale-125' : 'z-10'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
left: `${coords.x}%`,
|
||||||
|
top: `${coords.y}%`,
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
}}
|
||||||
|
title={`${viaje.folio}: ${viaje.origen}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex h-8 w-8 items-center justify-center rounded-full border-2 ${
|
||||||
|
isSelected
|
||||||
|
? 'border-primary-600 bg-primary-500'
|
||||||
|
: viaje.prioridad === 'ALTA'
|
||||||
|
? 'border-red-400 bg-red-100'
|
||||||
|
: viaje.prioridad === 'MEDIA'
|
||||||
|
? 'border-yellow-400 bg-yellow-100'
|
||||||
|
: 'border-gray-400 bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`h-4 w-4 ${isSelected ? 'text-white' : 'text-gray-700'}`}
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Unit positions (truck icons) */}
|
||||||
|
{unidades
|
||||||
|
.filter((u) => u.ubicacionActual)
|
||||||
|
.map((unidad) => {
|
||||||
|
const coords = transformCoords(
|
||||||
|
unidad.ubicacionActual!.latitud,
|
||||||
|
unidad.ubicacionActual!.longitud
|
||||||
|
);
|
||||||
|
const isSelected = selectedUnidad === unidad.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`unidad-${unidad.id}`}
|
||||||
|
onClick={() => onSelectUnidad(isSelected ? null : unidad.id)}
|
||||||
|
className={`absolute cursor-pointer transition-all ${
|
||||||
|
isSelected ? 'z-30 scale-125' : 'z-20'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
left: `${coords.x}%`,
|
||||||
|
top: `${coords.y}%`,
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
}}
|
||||||
|
title={`${unidad.numeroEconomico} - ${statusLabels[unidad.estado]}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex h-8 w-8 items-center justify-center rounded-lg ${
|
||||||
|
statusColors[unidad.estado]
|
||||||
|
} ${isSelected ? 'ring-2 ring-white ring-offset-2' : ''}`}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 text-white"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 17a2 2 0 11-4 0 2 2 0 014 0zM19 17a2 2 0 11-4 0 2 2 0 014 0z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13 16V6a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h1m8-1a1 1 0 01-1 1H9m4-1V8a1 1 0 011-1h2.586a1 1 0 01.707.293l3.414 3.414a1 1 0 01.293.707V16a1 1 0 01-1 1h-1m-6-1a1 1 0 001 1h1M5 17a2 2 0 104 0m-4 0a2 2 0 114 0m6 0a2 2 0 104 0m-4 0a2 2 0 114 0"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{isSelected && (
|
||||||
|
<div className="absolute left-1/2 top-full mt-1 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white">
|
||||||
|
{unidad.numeroEconomico}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="absolute bottom-4 right-4 rounded-lg bg-white p-3 shadow-lg">
|
||||||
|
<h4 className="mb-2 text-xs font-semibold text-gray-700">Estado de Unidades</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
|
||||||
|
{Object.entries(statusColors).map(([status, color]) => (
|
||||||
|
<div key={status} className="flex items-center gap-1.5">
|
||||||
|
<div className={`h-2.5 w-2.5 rounded ${color}`} />
|
||||||
|
<span className="text-xs text-gray-600">
|
||||||
|
{statusLabels[status as EstadoUnidadDespacho]} ({statusCounts[status] || 0})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Map integration note */}
|
||||||
|
<div className="absolute left-4 top-4 rounded bg-yellow-100 px-2 py-1 text-xs text-yellow-800">
|
||||||
|
Integrar con Leaflet/Mapbox
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="absolute right-4 top-4 rounded-lg bg-white px-3 py-2 shadow">
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-900">{unidades.length}</span>
|
||||||
|
<span className="ml-1 text-gray-500">unidades</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-900">{viajes.length}</span>
|
||||||
|
<span className="ml-1 text-gray-500">viajes</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
src/features/despacho/components/UnidadStatusPanel.tsx
Normal file
206
src/features/despacho/components/UnidadStatusPanel.tsx
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* Unidad Status Panel
|
||||||
|
* ERP Transportistas
|
||||||
|
* Sprint S6 - TASK-007
|
||||||
|
*
|
||||||
|
* Panel showing selected unit details
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EstadoUnidadDespacho, type EstadoUnidad } from '../types';
|
||||||
|
|
||||||
|
interface UnidadStatusPanelProps {
|
||||||
|
unidad: EstadoUnidad | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onLiberarUnidad: (unidadId: string) => void;
|
||||||
|
onActualizarEstado: (unidadId: string, estado: EstadoUnidadDespacho) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<EstadoUnidadDespacho, string> = {
|
||||||
|
DISPONIBLE: 'bg-green-100 text-green-800',
|
||||||
|
EN_VIAJE: 'bg-blue-100 text-blue-800',
|
||||||
|
EN_CARGA: 'bg-yellow-100 text-yellow-800',
|
||||||
|
EN_DESCARGA: 'bg-orange-100 text-orange-800',
|
||||||
|
EN_MANTENIMIENTO: 'bg-red-100 text-red-800',
|
||||||
|
FUERA_SERVICIO: 'bg-gray-100 text-gray-800',
|
||||||
|
EN_DESCANSO: 'bg-purple-100 text-purple-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<EstadoUnidadDespacho, string> = {
|
||||||
|
DISPONIBLE: 'Disponible',
|
||||||
|
EN_VIAJE: 'En Viaje',
|
||||||
|
EN_CARGA: 'En Carga',
|
||||||
|
EN_DESCARGA: 'En Descarga',
|
||||||
|
EN_MANTENIMIENTO: 'En Mantenimiento',
|
||||||
|
FUERA_SERVICIO: 'Fuera de Servicio',
|
||||||
|
EN_DESCANSO: 'En Descanso',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UnidadStatusPanel({
|
||||||
|
unidad,
|
||||||
|
onClose,
|
||||||
|
onLiberarUnidad,
|
||||||
|
onActualizarEstado,
|
||||||
|
}: UnidadStatusPanelProps) {
|
||||||
|
if (!unidad) return null;
|
||||||
|
|
||||||
|
const formatMinutes = (minutes: number) => {
|
||||||
|
if (minutes < 60) return `${minutes} min`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
return `${hours}h ${mins}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white shadow-lg">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6 text-gray-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 17a2 2 0 11-4 0 2 2 0 014 0zM19 17a2 2 0 11-4 0 2 2 0 014 0z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13 16V6a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h1m8-1a1 1 0 01-1 1H9m4-1V8a1 1 0 011-1h2.586a1 1 0 01.707.293l3.414 3.414a1 1 0 01.293.707V16a1 1 0 01-1 1h-1m-6-1a1 1 0 001 1h1M5 17a2 2 0 104 0m-4 0a2 2 0 114 0m6 0a2 2 0 104 0m-4 0a2 2 0 114 0"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">{unidad.numeroEconomico}</h3>
|
||||||
|
<span
|
||||||
|
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||||
|
statusColors[unidad.estado]
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{statusLabels[unidad.estado]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
{/* Operator info */}
|
||||||
|
{unidad.operadorNombre && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium uppercase text-gray-500">Operador</label>
|
||||||
|
<p className="mt-1 text-sm text-gray-900">{unidad.operadorNombre}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current trip */}
|
||||||
|
{unidad.folioViaje && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium uppercase text-gray-500">Viaje Actual</label>
|
||||||
|
<p className="mt-1 text-sm font-medium text-primary-600">{unidad.folioViaje}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
{unidad.ubicacionActual && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium uppercase text-gray-500">Ubicación</label>
|
||||||
|
<p className="mt-1 text-sm text-gray-900">
|
||||||
|
{unidad.ubicacionActual.latitud.toFixed(4)}, {unidad.ubicacionActual.longitud.toFixed(4)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Time in status */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium uppercase text-gray-500">Tiempo en Estado</label>
|
||||||
|
<p className="mt-1 text-sm text-gray-900">{formatMinutes(unidad.tiempoEnEstado)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last update */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium uppercase text-gray-500">Última Actualización</label>
|
||||||
|
<p className="mt-1 text-sm text-gray-900">
|
||||||
|
{new Date(unidad.ultimaActualizacion).toLocaleString('es-MX')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{unidad.notas && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium uppercase text-gray-500">Notas</label>
|
||||||
|
<p className="mt-1 text-sm text-gray-600">{unidad.notas}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="border-t border-gray-200 px-4 py-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{unidad.estado === EstadoUnidadDespacho.EN_VIAJE && (
|
||||||
|
<button
|
||||||
|
onClick={() => onLiberarUnidad(unidad.id)}
|
||||||
|
className="flex-1 rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Liberar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{unidad.estado === EstadoUnidadDespacho.DISPONIBLE && (
|
||||||
|
<button
|
||||||
|
onClick={() => onActualizarEstado(unidad.id, EstadoUnidadDespacho.EN_DESCANSO)}
|
||||||
|
className="flex-1 rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Marcar Descanso
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{unidad.estado === EstadoUnidadDespacho.EN_DESCANSO && (
|
||||||
|
<button
|
||||||
|
onClick={() => onActualizarEstado(unidad.id, EstadoUnidadDespacho.DISPONIBLE)}
|
||||||
|
className="flex-1 rounded-md bg-green-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-green-700"
|
||||||
|
>
|
||||||
|
Marcar Disponible
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => onActualizarEstado(unidad.id, EstadoUnidadDespacho.EN_MANTENIMIENTO)}
|
||||||
|
className="rounded-md border border-red-300 px-3 py-1.5 text-sm font-medium text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
Mantenimiento
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick status change */}
|
||||||
|
<div className="border-t border-gray-100 px-4 py-2">
|
||||||
|
<label className="text-xs font-medium text-gray-500">Cambiar Estado:</label>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{Object.entries(statusLabels)
|
||||||
|
.filter(([status]) => status !== unidad.estado)
|
||||||
|
.map(([status, label]) => (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
onClick={() => onActualizarEstado(unidad.id, status as EstadoUnidadDespacho)}
|
||||||
|
className={`rounded px-2 py-0.5 text-xs ${statusColors[status as EstadoUnidadDespacho]} hover:opacity-80`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
170
src/features/despacho/components/ViajesPendientesPanel.tsx
Normal file
170
src/features/despacho/components/ViajesPendientesPanel.tsx
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* Viajes Pendientes Panel
|
||||||
|
* ERP Transportistas
|
||||||
|
* Sprint S6 - TASK-007
|
||||||
|
*
|
||||||
|
* Panel showing pending trips to be assigned
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ViajePendiente } from '../types';
|
||||||
|
|
||||||
|
interface ViajesPendientesPanelProps {
|
||||||
|
viajes: ViajePendiente[];
|
||||||
|
selectedViaje?: string | null;
|
||||||
|
onSelectViaje: (id: string | null) => void;
|
||||||
|
onAsignar: (viaje: ViajePendiente) => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prioridadColors = {
|
||||||
|
ALTA: 'bg-red-100 text-red-800 border-red-200',
|
||||||
|
MEDIA: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||||
|
BAJA: 'bg-green-100 text-green-800 border-green-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ViajesPendientesPanel({
|
||||||
|
viajes,
|
||||||
|
selectedViaje,
|
||||||
|
onSelectViaje,
|
||||||
|
onAsignar,
|
||||||
|
loading = false,
|
||||||
|
}: ViajesPendientesPanelProps) {
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString('es-MX', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (timeStr: string) => {
|
||||||
|
return timeStr.substring(0, 5);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
|
||||||
|
<h3 className="font-semibold text-gray-900">Viajes Pendientes</h3>
|
||||||
|
<span className="rounded-full bg-primary-100 px-2.5 py-0.5 text-sm font-medium text-primary-700">
|
||||||
|
{viajes.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-32 items-center justify-center text-gray-500">
|
||||||
|
Cargando viajes...
|
||||||
|
</div>
|
||||||
|
) : viajes.length === 0 ? (
|
||||||
|
<div className="flex h-32 items-center justify-center text-gray-500">
|
||||||
|
No hay viajes pendientes
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-gray-100">
|
||||||
|
{viajes.map((viaje) => (
|
||||||
|
<li
|
||||||
|
key={viaje.id}
|
||||||
|
onClick={() => onSelectViaje(selectedViaje === viaje.id ? null : viaje.id)}
|
||||||
|
className={`cursor-pointer p-3 transition-colors hover:bg-gray-50 ${
|
||||||
|
selectedViaje === viaje.id ? 'bg-primary-50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Header row */}
|
||||||
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
|
<span className="font-medium text-gray-900">{viaje.folio}</span>
|
||||||
|
<span
|
||||||
|
className={`rounded-full border px-2 py-0.5 text-xs font-medium ${
|
||||||
|
prioridadColors[viaje.prioridad]
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{viaje.prioridad}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Route */}
|
||||||
|
<div className="mb-2 text-sm text-gray-600">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<svg className="h-3.5 w-3.5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<circle cx="10" cy="10" r="4" />
|
||||||
|
</svg>
|
||||||
|
<span className="truncate">{viaje.origen}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ml-1.5 border-l-2 border-gray-200 pl-2.5 text-xs text-gray-400">
|
||||||
|
↓
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<svg className="h-3.5 w-3.5 text-red-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<circle cx="10" cy="10" r="4" />
|
||||||
|
</svg>
|
||||||
|
<span className="truncate">{viaje.destino}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details row */}
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{formatDate(viaje.fechaCita)}</span>
|
||||||
|
<span className="font-medium text-gray-700">{formatTime(viaje.horaCita)}</span>
|
||||||
|
</div>
|
||||||
|
<span className="truncate max-w-[100px]">{viaje.clienteNombre}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Requirements badges */}
|
||||||
|
{viaje.requisitos.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{viaje.requisitos.map((req) => (
|
||||||
|
<span
|
||||||
|
key={req}
|
||||||
|
className="rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600"
|
||||||
|
>
|
||||||
|
{req}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{selectedViaje === viaje.id && (
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAsignar(viaje);
|
||||||
|
}}
|
||||||
|
className="flex-1 rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700"
|
||||||
|
>
|
||||||
|
Asignar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelectViaje(null);
|
||||||
|
}}
|
||||||
|
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer with filters */}
|
||||||
|
<div className="border-t border-gray-200 bg-gray-50 px-4 py-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
<span>Filtrar:</span>
|
||||||
|
<button className="rounded border border-gray-300 bg-white px-2 py-0.5 hover:bg-gray-50">
|
||||||
|
Hoy
|
||||||
|
</button>
|
||||||
|
<button className="rounded border border-gray-300 bg-white px-2 py-0.5 hover:bg-gray-50">
|
||||||
|
Alta prioridad
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/features/despacho/components/index.ts
Normal file
10
src/features/despacho/components/index.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Despacho Components Index
|
||||||
|
* ERP Transportistas
|
||||||
|
* Sprint S6 - TASK-007
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { DispatchMap } from './DispatchMap';
|
||||||
|
export { ViajesPendientesPanel } from './ViajesPendientesPanel';
|
||||||
|
export { UnidadStatusPanel } from './UnidadStatusPanel';
|
||||||
|
export { AsignacionModal } from './AsignacionModal';
|
||||||
21
src/features/despacho/index.ts
Normal file
21
src/features/despacho/index.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Despacho Feature Module
|
||||||
|
* ERP Transportistas
|
||||||
|
* Sprint S6 - TASK-007
|
||||||
|
*
|
||||||
|
* Dispatch board feature for assigning trips to units/operators.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export * from './types';
|
||||||
|
|
||||||
|
// API
|
||||||
|
export { despachoApi } from './api/despacho.api';
|
||||||
|
|
||||||
|
// Components
|
||||||
|
export {
|
||||||
|
DispatchMap,
|
||||||
|
ViajesPendientesPanel,
|
||||||
|
UnidadStatusPanel,
|
||||||
|
AsignacionModal,
|
||||||
|
} from './components';
|
||||||
203
src/features/despacho/types/index.ts
Normal file
203
src/features/despacho/types/index.ts
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Despacho Types
|
||||||
|
* ERP Transportistas
|
||||||
|
* Sprint S6 - TASK-007
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ==================== Estados ====================
|
||||||
|
|
||||||
|
export enum EstadoUnidadDespacho {
|
||||||
|
DISPONIBLE = 'DISPONIBLE',
|
||||||
|
EN_VIAJE = 'EN_VIAJE',
|
||||||
|
EN_CARGA = 'EN_CARGA',
|
||||||
|
EN_DESCARGA = 'EN_DESCARGA',
|
||||||
|
EN_MANTENIMIENTO = 'EN_MANTENIMIENTO',
|
||||||
|
FUERA_SERVICIO = 'FUERA_SERVICIO',
|
||||||
|
EN_DESCANSO = 'EN_DESCANSO',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum EstadoViajeDespacho {
|
||||||
|
PENDIENTE = 'PENDIENTE',
|
||||||
|
ASIGNADO = 'ASIGNADO',
|
||||||
|
CONFIRMADO = 'CONFIRMADO',
|
||||||
|
EN_TRANSITO = 'EN_TRANSITO',
|
||||||
|
COMPLETADO = 'COMPLETADO',
|
||||||
|
CANCELADO = 'CANCELADO',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TipoCertificacion {
|
||||||
|
LIC_FEDERAL = 'LIC_FEDERAL',
|
||||||
|
CERT_MP = 'CERT_MP',
|
||||||
|
HAZMAT = 'HAZMAT',
|
||||||
|
REFRIGERADO = 'REFRIGERADO',
|
||||||
|
SOBREDIMENSIONADO = 'SOBREDIMENSIONADO',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Entidades ====================
|
||||||
|
|
||||||
|
export interface TableroDespacho {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
zona?: string;
|
||||||
|
activo: boolean;
|
||||||
|
configuracion?: Record<string, unknown>;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EstadoUnidad {
|
||||||
|
id: string;
|
||||||
|
unidadId: string;
|
||||||
|
numeroEconomico: string;
|
||||||
|
operadorId?: string;
|
||||||
|
operadorNombre?: string;
|
||||||
|
estado: EstadoUnidadDespacho;
|
||||||
|
viajeActualId?: string;
|
||||||
|
folioViaje?: string;
|
||||||
|
ubicacionActual?: {
|
||||||
|
latitud: number;
|
||||||
|
longitud: number;
|
||||||
|
};
|
||||||
|
ultimaActualizacion: string;
|
||||||
|
tiempoEnEstado: number; // minutos
|
||||||
|
notas?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OperadorCertificacion {
|
||||||
|
id: string;
|
||||||
|
operadorId: string;
|
||||||
|
tipo: TipoCertificacion;
|
||||||
|
numero?: string;
|
||||||
|
fechaEmision: string;
|
||||||
|
fechaVencimiento: string;
|
||||||
|
activa: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnoOperador {
|
||||||
|
id: string;
|
||||||
|
operadorId: string;
|
||||||
|
operadorNombre: string;
|
||||||
|
fechaInicio: string;
|
||||||
|
fechaFin?: string;
|
||||||
|
horasTrabajadas: number;
|
||||||
|
estado: 'ACTIVO' | 'EN_DESCANSO' | 'FINALIZADO';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViajePendiente {
|
||||||
|
id: string;
|
||||||
|
folio: string;
|
||||||
|
origen: string;
|
||||||
|
destino: string;
|
||||||
|
origenCoords?: {
|
||||||
|
latitud: number;
|
||||||
|
longitud: number;
|
||||||
|
};
|
||||||
|
destinoCoords?: {
|
||||||
|
latitud: number;
|
||||||
|
longitud: number;
|
||||||
|
};
|
||||||
|
fechaCita: string;
|
||||||
|
horaCita: string;
|
||||||
|
clienteNombre: string;
|
||||||
|
tipoServicio: string;
|
||||||
|
requisitos: TipoCertificacion[];
|
||||||
|
prioridad: 'ALTA' | 'MEDIA' | 'BAJA';
|
||||||
|
estado: EstadoViajeDespacho;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SugerenciaAsignacion {
|
||||||
|
unidadId: string;
|
||||||
|
numeroEconomico: string;
|
||||||
|
operadorId: string;
|
||||||
|
operadorNombre: string;
|
||||||
|
score: number;
|
||||||
|
distanciaKm: number;
|
||||||
|
tiempoEstimadoMin: number;
|
||||||
|
factores: {
|
||||||
|
disponibilidad: number;
|
||||||
|
distancia: number;
|
||||||
|
certificaciones: number;
|
||||||
|
horasTrabajadas: number;
|
||||||
|
};
|
||||||
|
certificacionesCumplidas: TipoCertificacion[];
|
||||||
|
certificacionesFaltantes: TipoCertificacion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogDespacho {
|
||||||
|
id: string;
|
||||||
|
tableroId: string;
|
||||||
|
viajeId: string;
|
||||||
|
unidadId: string;
|
||||||
|
operadorId: string;
|
||||||
|
tipoAccion: 'ASIGNACION' | 'REASIGNACION' | 'CANCELACION' | 'LIBERACION';
|
||||||
|
motivo?: string;
|
||||||
|
scoreAsignacion?: number;
|
||||||
|
usuarioId: string;
|
||||||
|
usuarioNombre: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DTOs ====================
|
||||||
|
|
||||||
|
export interface AsignarViajeDto {
|
||||||
|
viajeId: string;
|
||||||
|
unidadId: string;
|
||||||
|
operadorId: string;
|
||||||
|
motivo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActualizarEstadoUnidadDto {
|
||||||
|
estado: EstadoUnidadDespacho;
|
||||||
|
notas?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SincronizarGpsDto {
|
||||||
|
tableroId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Responses ====================
|
||||||
|
|
||||||
|
export interface TablerosResponse {
|
||||||
|
data: TableroDespacho[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EstadosUnidadResponse {
|
||||||
|
data: EstadoUnidad[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViajesPendientesResponse {
|
||||||
|
data: ViajePendiente[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SugerenciasResponse {
|
||||||
|
data: SugerenciaAsignacion[];
|
||||||
|
viajeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogsDespachoResponse {
|
||||||
|
data: LogDespacho[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Filters ====================
|
||||||
|
|
||||||
|
export interface DespachoFilters {
|
||||||
|
tableroId?: string;
|
||||||
|
estado?: EstadoUnidadDespacho;
|
||||||
|
zona?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViajesFilters {
|
||||||
|
estado?: EstadoViajeDespacho;
|
||||||
|
prioridad?: 'ALTA' | 'MEDIA' | 'BAJA';
|
||||||
|
fechaDesde?: string;
|
||||||
|
fechaHasta?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
168
src/features/tracking/components/ETAProgressBar.tsx
Normal file
168
src/features/tracking/components/ETAProgressBar.tsx
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* ETA Progress Bar
|
||||||
|
* ERP Transportistas
|
||||||
|
* Sprint S7 - TASK-007
|
||||||
|
*
|
||||||
|
* Visual progress indicator for trip ETA.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TipoEventoTracking } from '../types';
|
||||||
|
|
||||||
|
interface ETAProgressBarProps {
|
||||||
|
progreso: number;
|
||||||
|
etaOriginal: string;
|
||||||
|
etaActual: string;
|
||||||
|
ultimoEvento?: TipoEventoTracking;
|
||||||
|
}
|
||||||
|
|
||||||
|
const etapaLabels: Record<TipoEventoTracking, string> = {
|
||||||
|
[TipoEventoTracking.POSICION]: 'En ruta',
|
||||||
|
[TipoEventoTracking.SALIDA]: 'Salida',
|
||||||
|
[TipoEventoTracking.ARRIBO_ORIGEN]: 'En origen',
|
||||||
|
[TipoEventoTracking.INICIO_CARGA]: 'Cargando',
|
||||||
|
[TipoEventoTracking.FIN_CARGA]: 'Carga lista',
|
||||||
|
[TipoEventoTracking.ARRIBO_DESTINO]: 'En destino',
|
||||||
|
[TipoEventoTracking.INICIO_DESCARGA]: 'Descargando',
|
||||||
|
[TipoEventoTracking.FIN_DESCARGA]: 'Descarga lista',
|
||||||
|
[TipoEventoTracking.ENTREGA_POD]: 'Entregado',
|
||||||
|
[TipoEventoTracking.DESVIO]: 'Desvío',
|
||||||
|
[TipoEventoTracking.PARADA]: 'Detenido',
|
||||||
|
[TipoEventoTracking.INCIDENTE]: 'Incidente',
|
||||||
|
[TipoEventoTracking.GPS_POSICION]: 'En ruta',
|
||||||
|
[TipoEventoTracking.GEOCERCA_ENTRADA]: 'Entrando zona',
|
||||||
|
[TipoEventoTracking.GEOCERCA_SALIDA]: 'Saliendo zona',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ETAProgressBar({
|
||||||
|
progreso,
|
||||||
|
etaOriginal,
|
||||||
|
etaActual,
|
||||||
|
ultimoEvento,
|
||||||
|
}: ETAProgressBarProps) {
|
||||||
|
const etaOriginalDate = new Date(etaOriginal);
|
||||||
|
const etaActualDate = new Date(etaActual);
|
||||||
|
const diferenciaMinutos = Math.round(
|
||||||
|
(etaActualDate.getTime() - etaOriginalDate.getTime()) / 60000
|
||||||
|
);
|
||||||
|
|
||||||
|
const getStatusColor = () => {
|
||||||
|
if (diferenciaMinutos <= 0) return 'bg-green-500';
|
||||||
|
if (diferenciaMinutos <= 30) return 'bg-yellow-500';
|
||||||
|
return 'bg-red-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = () => {
|
||||||
|
if (diferenciaMinutos <= 0) return 'A tiempo';
|
||||||
|
if (diferenciaMinutos <= 30) return `+${diferenciaMinutos} min`;
|
||||||
|
const horas = Math.floor(diferenciaMinutos / 60);
|
||||||
|
const mins = diferenciaMinutos % 60;
|
||||||
|
return horas > 0 ? `+${horas}h ${mins}m` : `+${mins} min`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const milestones = [
|
||||||
|
{ label: 'Salida', position: 0 },
|
||||||
|
{ label: 'Carga', position: 25 },
|
||||||
|
{ label: 'En ruta', position: 50 },
|
||||||
|
{ label: 'Descarga', position: 75 },
|
||||||
|
{ label: 'POD', position: 100 },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Status row */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-gray-700">Progreso:</span>
|
||||||
|
{ultimoEvento && (
|
||||||
|
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
|
||||||
|
{etapaLabels[ultimoEvento] || ultimoEvento}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||||
|
diferenciaMinutos <= 0
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: diferenciaMinutos <= 30
|
||||||
|
? 'bg-yellow-100 text-yellow-700'
|
||||||
|
: 'bg-red-100 text-red-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{getStatusText()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="h-3 overflow-hidden rounded-full bg-gray-200">
|
||||||
|
<div
|
||||||
|
className={`h-full transition-all duration-500 ${getStatusColor()}`}
|
||||||
|
style={{ width: `${Math.min(progreso, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Milestone markers */}
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
{milestones.map((milestone, index) => (
|
||||||
|
<div
|
||||||
|
key={milestone.label}
|
||||||
|
className="absolute flex flex-col items-center"
|
||||||
|
style={{
|
||||||
|
left: `${milestone.position}%`,
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`h-3 w-3 rounded-full border-2 ${
|
||||||
|
progreso >= milestone.position
|
||||||
|
? 'border-white bg-white'
|
||||||
|
: 'border-gray-400 bg-gray-200'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Milestone labels */}
|
||||||
|
<div className="relative h-4">
|
||||||
|
{milestones.map((milestone) => (
|
||||||
|
<div
|
||||||
|
key={`label-${milestone.label}`}
|
||||||
|
className="absolute text-xs text-gray-500"
|
||||||
|
style={{
|
||||||
|
left: `${milestone.position}%`,
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{milestone.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ETA comparison */}
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">ETA Original: </span>
|
||||||
|
<span className="font-medium text-gray-600">
|
||||||
|
{etaOriginalDate.toLocaleString('es-MX', {
|
||||||
|
dateStyle: 'short',
|
||||||
|
timeStyle: 'short',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">ETA Actual: </span>
|
||||||
|
<span className={`font-medium ${diferenciaMinutos > 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||||
|
{etaActualDate.toLocaleString('es-MX', {
|
||||||
|
dateStyle: 'short',
|
||||||
|
timeStyle: 'short',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
248
src/features/tracking/components/EventTimeline.tsx
Normal file
248
src/features/tracking/components/EventTimeline.tsx
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* Event Timeline
|
||||||
|
* ERP Transportistas
|
||||||
|
* Sprint S7 - TASK-007
|
||||||
|
*
|
||||||
|
* Timeline view of trip events.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TipoEventoTracking, FuenteEvento, type EventoTracking } from '../types';
|
||||||
|
|
||||||
|
interface EventTimelineProps {
|
||||||
|
eventos: EventoTracking[];
|
||||||
|
loading?: boolean;
|
||||||
|
viajeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tipoEventoConfig: Record<TipoEventoTracking, { label: string; icon: string; color: string }> = {
|
||||||
|
[TipoEventoTracking.POSICION]: {
|
||||||
|
label: 'Posición GPS',
|
||||||
|
icon: '📍',
|
||||||
|
color: 'bg-gray-100 text-gray-600',
|
||||||
|
},
|
||||||
|
[TipoEventoTracking.SALIDA]: {
|
||||||
|
label: 'Salida',
|
||||||
|
icon: '🚚',
|
||||||
|
color: 'bg-green-100 text-green-700',
|
||||||
|
},
|
||||||
|
[TipoEventoTracking.ARRIBO_ORIGEN]: {
|
||||||
|
label: 'Arribo a Origen',
|
||||||
|
icon: '📦',
|
||||||
|
color: 'bg-blue-100 text-blue-700',
|
||||||
|
},
|
||||||
|
[TipoEventoTracking.INICIO_CARGA]: {
|
||||||
|
label: 'Inicio de Carga',
|
||||||
|
icon: '⬆️',
|
||||||
|
color: 'bg-blue-100 text-blue-700',
|
||||||
|
},
|
||||||
|
[TipoEventoTracking.FIN_CARGA]: {
|
||||||
|
label: 'Fin de Carga',
|
||||||
|
icon: '✅',
|
||||||
|
color: 'bg-blue-100 text-blue-700',
|
||||||
|
},
|
||||||
|
[TipoEventoTracking.ARRIBO_DESTINO]: {
|
||||||
|
label: 'Arribo a Destino',
|
||||||
|
icon: '🏁',
|
||||||
|
color: 'bg-purple-100 text-purple-700',
|
||||||
|
},
|
||||||
|
[TipoEventoTracking.INICIO_DESCARGA]: {
|
||||||
|
label: 'Inicio de Descarga',
|
||||||
|
icon: '⬇️',
|
||||||
|
color: 'bg-purple-100 text-purple-700',
|
||||||
|
},
|
||||||
|
[TipoEventoTracking.FIN_DESCARGA]: {
|
||||||
|
label: 'Fin de Descarga',
|
||||||
|
icon: '✅',
|
||||||
|
color: 'bg-purple-100 text-purple-700',
|
||||||
|
},
|
||||||
|
[TipoEventoTracking.ENTREGA_POD]: {
|
||||||
|
label: 'POD Entregado',
|
||||||
|
icon: '📝',
|
||||||
|
color: 'bg-green-100 text-green-700',
|
||||||
|
},
|
||||||
|
[TipoEventoTracking.DESVIO]: {
|
||||||
|
label: 'Desvío Detectado',
|
||||||
|
icon: '⚠️',
|
||||||
|
color: 'bg-yellow-100 text-yellow-700',
|
||||||
|
},
|
||||||
|
[TipoEventoTracking.PARADA]: {
|
||||||
|
label: 'Parada',
|
||||||
|
icon: '⏸️',
|
||||||
|
color: 'bg-orange-100 text-orange-700',
|
||||||
|
},
|
||||||
|
[TipoEventoTracking.INCIDENTE]: {
|
||||||
|
label: 'Incidente',
|
||||||
|
icon: '🚨',
|
||||||
|
color: 'bg-red-100 text-red-700',
|
||||||
|
},
|
||||||
|
[TipoEventoTracking.GPS_POSICION]: {
|
||||||
|
label: 'Posición GPS',
|
||||||
|
icon: '📍',
|
||||||
|
color: 'bg-gray-100 text-gray-600',
|
||||||
|
},
|
||||||
|
[TipoEventoTracking.GEOCERCA_ENTRADA]: {
|
||||||
|
label: 'Entrada a Geocerca',
|
||||||
|
icon: '🔵',
|
||||||
|
color: 'bg-cyan-100 text-cyan-700',
|
||||||
|
},
|
||||||
|
[TipoEventoTracking.GEOCERCA_SALIDA]: {
|
||||||
|
label: 'Salida de Geocerca',
|
||||||
|
icon: '🔴',
|
||||||
|
color: 'bg-cyan-100 text-cyan-700',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const fuenteLabels: Record<FuenteEvento, string> = {
|
||||||
|
[FuenteEvento.GPS]: 'GPS',
|
||||||
|
[FuenteEvento.APP_OPERADOR]: 'App Operador',
|
||||||
|
[FuenteEvento.SISTEMA]: 'Sistema',
|
||||||
|
[FuenteEvento.MANUAL]: 'Manual',
|
||||||
|
[FuenteEvento.GEOCERCA]: 'Geocerca',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EventTimeline({ eventos, loading = false, viajeId }: EventTimelineProps) {
|
||||||
|
// Filter out frequent GPS positions to show only meaningful events
|
||||||
|
const eventosImportantes = eventos.filter(
|
||||||
|
(e) =>
|
||||||
|
e.tipoEvento !== TipoEventoTracking.POSICION &&
|
||||||
|
e.tipoEvento !== TipoEventoTracking.GPS_POSICION
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show most recent first
|
||||||
|
const eventosSorted = [...eventosImportantes].sort(
|
||||||
|
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatTime = (timestamp: string) => {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleString('es-MX', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimeDiff = (timestamp: string) => {
|
||||||
|
const diff = Date.now() - new Date(timestamp).getTime();
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
if (minutes < 60) return `hace ${minutes} min`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `hace ${hours}h`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `hace ${days}d`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="border-b border-gray-200 bg-gray-50 px-4 py-3">
|
||||||
|
<h3 className="font-semibold text-gray-900">Timeline de Eventos</h3>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{eventosSorted.length} eventos registrados
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-32 items-center justify-center text-gray-500">
|
||||||
|
Cargando eventos...
|
||||||
|
</div>
|
||||||
|
) : eventosSorted.length === 0 ? (
|
||||||
|
<div className="flex h-32 items-center justify-center text-gray-500">
|
||||||
|
No hay eventos registrados
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="p-4">
|
||||||
|
{eventosSorted.map((evento, index) => {
|
||||||
|
const config = tipoEventoConfig[evento.tipoEvento] || {
|
||||||
|
label: evento.tipoEvento,
|
||||||
|
icon: '📌',
|
||||||
|
color: 'bg-gray-100 text-gray-600',
|
||||||
|
};
|
||||||
|
const isLast = index === eventosSorted.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={evento.id} className="relative pb-6">
|
||||||
|
{/* Timeline line */}
|
||||||
|
{!isLast && (
|
||||||
|
<div className="absolute left-4 top-8 h-full w-0.5 bg-gray-200" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{/* Icon */}
|
||||||
|
<div
|
||||||
|
className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full ${config.color}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm">{config.icon}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium text-gray-900 text-sm">
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{formatTimeDiff(evento.timestamp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-0.5 text-xs text-gray-500">
|
||||||
|
{formatTime(evento.timestamp)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
{evento.direccion && (
|
||||||
|
<div className="mt-1 text-xs text-gray-600">
|
||||||
|
📍 {evento.direccion}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{evento.descripcion && (
|
||||||
|
<div className="mt-1 text-xs text-gray-600">
|
||||||
|
{evento.descripcion}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Source badge */}
|
||||||
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
|
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-500">
|
||||||
|
{fuenteLabels[evento.fuente]}
|
||||||
|
</span>
|
||||||
|
{evento.latitud && evento.longitud && (
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{evento.latitud.toFixed(4)}, {evento.longitud.toFixed(4)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Additional data */}
|
||||||
|
{evento.velocidad !== undefined && evento.velocidad > 0 && (
|
||||||
|
<div className="mt-1 text-xs text-gray-500">
|
||||||
|
Velocidad: {evento.velocidad.toFixed(0)} km/h
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="border-t border-gray-200 bg-gray-50 px-4 py-2">
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||||
|
<span>Total posiciones GPS: {eventos.length - eventosImportantes.length}</span>
|
||||||
|
<button className="text-primary-600 hover:text-primary-800">
|
||||||
|
Ver todas
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
283
src/features/tracking/components/ViajeTrackingView.tsx
Normal file
283
src/features/tracking/components/ViajeTrackingView.tsx
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
/**
|
||||||
|
* Viaje Tracking View
|
||||||
|
* ERP Transportistas
|
||||||
|
* Sprint S7 - TASK-007
|
||||||
|
*
|
||||||
|
* Detailed tracking view for a specific trip with map, timeline and ETA.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { trackingApi } from '../api/tracking.api';
|
||||||
|
import { ETAProgressBar } from './ETAProgressBar';
|
||||||
|
import { EventTimeline } from './EventTimeline';
|
||||||
|
import { TipoEventoTracking, type EventoTracking } from '../types';
|
||||||
|
|
||||||
|
interface ViajeTrackingViewProps {
|
||||||
|
viajeId: string;
|
||||||
|
folio: string;
|
||||||
|
origen: string;
|
||||||
|
destino: string;
|
||||||
|
fechaSalida: string;
|
||||||
|
etaOriginal: string;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Milestone events for progress calculation
|
||||||
|
const MILESTONE_EVENTS = [
|
||||||
|
TipoEventoTracking.SALIDA,
|
||||||
|
TipoEventoTracking.ARRIBO_ORIGEN,
|
||||||
|
TipoEventoTracking.INICIO_CARGA,
|
||||||
|
TipoEventoTracking.FIN_CARGA,
|
||||||
|
TipoEventoTracking.ARRIBO_DESTINO,
|
||||||
|
TipoEventoTracking.INICIO_DESCARGA,
|
||||||
|
TipoEventoTracking.FIN_DESCARGA,
|
||||||
|
TipoEventoTracking.ENTREGA_POD,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ViajeTrackingView({
|
||||||
|
viajeId,
|
||||||
|
folio,
|
||||||
|
origen,
|
||||||
|
destino,
|
||||||
|
fechaSalida,
|
||||||
|
etaOriginal,
|
||||||
|
onClose,
|
||||||
|
}: ViajeTrackingViewProps) {
|
||||||
|
const [currentEta, setCurrentEta] = useState(etaOriginal);
|
||||||
|
|
||||||
|
// Fetch trip events
|
||||||
|
const { data: eventosData, isLoading: loadingEventos } = useQuery({
|
||||||
|
queryKey: ['tracking-viaje-eventos', viajeId],
|
||||||
|
queryFn: () => trackingApi.getEventosViaje(viajeId),
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch trip route (GPS positions)
|
||||||
|
const { data: rutaData, isLoading: loadingRuta } = useQuery({
|
||||||
|
queryKey: ['tracking-viaje-ruta', viajeId],
|
||||||
|
queryFn: () => trackingApi.getRutaViaje(viajeId),
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventos = eventosData?.data || [];
|
||||||
|
const ruta = rutaData?.data || [];
|
||||||
|
|
||||||
|
// Get current position (last GPS position)
|
||||||
|
const posicionActual = useMemo(() => {
|
||||||
|
const posiciones = ruta.filter((e) => e.latitud && e.longitud);
|
||||||
|
return posiciones[posiciones.length - 1] || null;
|
||||||
|
}, [ruta]);
|
||||||
|
|
||||||
|
// Calculate progress based on milestone events
|
||||||
|
const progreso = useMemo(() => {
|
||||||
|
const completedMilestones = eventos.filter((e) =>
|
||||||
|
MILESTONE_EVENTS.includes(e.tipoEvento)
|
||||||
|
).length;
|
||||||
|
return Math.min((completedMilestones / MILESTONE_EVENTS.length) * 100, 100);
|
||||||
|
}, [eventos]);
|
||||||
|
|
||||||
|
// Get last milestone event
|
||||||
|
const ultimoEvento = useMemo(() => {
|
||||||
|
const milestones = eventos.filter((e) =>
|
||||||
|
MILESTONE_EVENTS.includes(e.tipoEvento)
|
||||||
|
);
|
||||||
|
return milestones[milestones.length - 1] || null;
|
||||||
|
}, [eventos]);
|
||||||
|
|
||||||
|
// Calculate dynamic ETA based on speed and distance
|
||||||
|
useEffect(() => {
|
||||||
|
if (posicionActual && posicionActual.velocidad && posicionActual.velocidad > 0) {
|
||||||
|
// Simple ETA calculation (would use actual routing in production)
|
||||||
|
const remainingKm = 100; // Placeholder - would calculate from route
|
||||||
|
const hoursRemaining = remainingKm / posicionActual.velocidad;
|
||||||
|
const newEta = new Date(Date.now() + hoursRemaining * 60 * 60 * 1000);
|
||||||
|
setCurrentEta(newEta.toISOString());
|
||||||
|
}
|
||||||
|
}, [posicionActual]);
|
||||||
|
|
||||||
|
// Transform coordinates for simple map display
|
||||||
|
const transformCoords = (lat: number, lng: number) => {
|
||||||
|
const x = ((lng + 118) / 32) * 100;
|
||||||
|
const y = ((33 - lat) / 19) * 100;
|
||||||
|
return { x: Math.max(0, Math.min(100, x)), y: Math.max(0, Math.min(100, y)) };
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Tracking: {folio}</h2>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{origen} → {destino}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-xs text-gray-500">ETA</div>
|
||||||
|
<div className="font-medium text-gray-900">
|
||||||
|
{new Date(currentEta).toLocaleString('es-MX', {
|
||||||
|
dateStyle: 'short',
|
||||||
|
timeStyle: 'short',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="border-b border-gray-100 px-4 py-3">
|
||||||
|
<ETAProgressBar
|
||||||
|
progreso={progreso}
|
||||||
|
etaOriginal={etaOriginal}
|
||||||
|
etaActual={currentEta}
|
||||||
|
ultimoEvento={ultimoEvento?.tipoEvento}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* Map */}
|
||||||
|
<div className="flex-1 relative bg-gradient-to-br from-blue-50 via-green-50 to-yellow-50">
|
||||||
|
{loadingRuta ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-gray-500">
|
||||||
|
Cargando ruta...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Route polyline (placeholder) */}
|
||||||
|
<svg className="absolute inset-0 h-full w-full">
|
||||||
|
{ruta.length > 1 && (
|
||||||
|
<polyline
|
||||||
|
points={ruta
|
||||||
|
.filter((e) => e.latitud && e.longitud)
|
||||||
|
.map((e) => {
|
||||||
|
const coords = transformCoords(e.latitud!, e.longitud!);
|
||||||
|
return `${coords.x}%,${coords.y}%`;
|
||||||
|
})
|
||||||
|
.join(' ')}
|
||||||
|
fill="none"
|
||||||
|
stroke="#3B82F6"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Origin marker */}
|
||||||
|
<div
|
||||||
|
className="absolute z-10"
|
||||||
|
style={{
|
||||||
|
left: '15%',
|
||||||
|
top: '30%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-green-600 bg-green-100">
|
||||||
|
<span className="text-xs font-bold text-green-700">O</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Destination marker */}
|
||||||
|
<div
|
||||||
|
className="absolute z-10"
|
||||||
|
style={{
|
||||||
|
left: '85%',
|
||||||
|
top: '70%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-red-600 bg-red-100">
|
||||||
|
<span className="text-xs font-bold text-red-700">D</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current position marker */}
|
||||||
|
{posicionActual && (
|
||||||
|
<div
|
||||||
|
className="absolute z-20"
|
||||||
|
style={{
|
||||||
|
left: `${transformCoords(posicionActual.latitud!, posicionActual.longitud!).x}%`,
|
||||||
|
top: `${transformCoords(posicionActual.latitud!, posicionActual.longitud!).y}%`,
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-600 shadow-lg ring-4 ring-blue-200">
|
||||||
|
<svg className="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 17a2 2 0 11-4 0 2 2 0 014 0zM19 17a2 2 0 11-4 0 2 2 0 014 0z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13 16V6a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h1m8-1a1 1 0 01-1 1H9m4-1V8a1 1 0 011-1h2.586a1 1 0 01.707.293l3.414 3.414a1 1 0 01.293.707V16a1 1 0 01-1 1h-1m-6-1a1 1 0 001 1h1M5 17a2 2 0 104 0m-4 0a2 2 0 114 0m6 0a2 2 0 104 0m-4 0a2 2 0 114 0"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{/* Speed indicator */}
|
||||||
|
{posicionActual.velocidad !== undefined && (
|
||||||
|
<div className="absolute -bottom-6 left-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-0.5 text-xs text-white">
|
||||||
|
{posicionActual.velocidad.toFixed(0)} km/h
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Map note */}
|
||||||
|
<div className="absolute left-4 top-4 rounded bg-yellow-100 px-2 py-1 text-xs text-yellow-800">
|
||||||
|
Integrar con Leaflet/Mapbox
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline sidebar */}
|
||||||
|
<div className="w-80 flex-shrink-0 overflow-y-auto border-l border-gray-200">
|
||||||
|
<EventTimeline
|
||||||
|
eventos={eventos}
|
||||||
|
loading={loadingEventos}
|
||||||
|
viajeId={viajeId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer with stats */}
|
||||||
|
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-4 py-2 text-sm">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-gray-500">
|
||||||
|
Posiciones: <span className="font-medium text-gray-900">{ruta.length}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500">
|
||||||
|
Eventos: <span className="font-medium text-gray-900">{eventos.length}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{posicionActual && (
|
||||||
|
<span className="text-gray-500">
|
||||||
|
Última actualización:{' '}
|
||||||
|
<span className="font-medium text-gray-900">
|
||||||
|
{new Date(posicionActual.timestamp).toLocaleTimeString('es-MX')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,3 +1,6 @@
|
|||||||
export { TrackingMap } from './TrackingMap';
|
export { TrackingMap } from './TrackingMap';
|
||||||
export { EventosList } from './EventosList';
|
export { EventosList } from './EventosList';
|
||||||
export { GeocercasList } from './GeocercasList';
|
export { GeocercasList } from './GeocercasList';
|
||||||
|
export { ViajeTrackingView } from './ViajeTrackingView';
|
||||||
|
export { ETAProgressBar } from './ETAProgressBar';
|
||||||
|
export { EventTimeline } from './EventTimeline';
|
||||||
|
|||||||
11
src/features/tracking/hooks/index.ts
Normal file
11
src/features/tracking/hooks/index.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Tracking Hooks Index
|
||||||
|
* ERP Transportistas
|
||||||
|
* Sprint S7 - TASK-007
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
useTrackingWebSocket,
|
||||||
|
useTrackingPositions,
|
||||||
|
useViajeTracking,
|
||||||
|
} from './useTrackingWebSocket';
|
||||||
239
src/features/tracking/hooks/useTrackingWebSocket.ts
Normal file
239
src/features/tracking/hooks/useTrackingWebSocket.ts
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
/**
|
||||||
|
* useTrackingWebSocket Hook
|
||||||
|
* ERP Transportistas
|
||||||
|
* Sprint S7 - TASK-007
|
||||||
|
*
|
||||||
|
* Hook for real-time tracking updates via WebSocket.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import type { EventoTracking, PosicionActual } from '../types';
|
||||||
|
|
||||||
|
interface WebSocketMessage {
|
||||||
|
type: 'POSICION' | 'EVENTO' | 'ALERTA' | 'ETA_UPDATE';
|
||||||
|
payload: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseTrackingWebSocketOptions {
|
||||||
|
viajeId?: string;
|
||||||
|
unidadIds?: string[];
|
||||||
|
onPosicion?: (posicion: PosicionActual) => void;
|
||||||
|
onEvento?: (evento: EventoTracking) => void;
|
||||||
|
onAlerta?: (alerta: { tipo: string; mensaje: string; viajeId?: string }) => void;
|
||||||
|
onEtaUpdate?: (data: { viajeId: string; nuevoEta: string; motivo: string }) => void;
|
||||||
|
autoReconnect?: boolean;
|
||||||
|
reconnectInterval?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseTrackingWebSocketReturn {
|
||||||
|
isConnected: boolean;
|
||||||
|
lastMessage: WebSocketMessage | null;
|
||||||
|
error: Error | null;
|
||||||
|
connect: () => void;
|
||||||
|
disconnect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTrackingWebSocket({
|
||||||
|
viajeId,
|
||||||
|
unidadIds,
|
||||||
|
onPosicion,
|
||||||
|
onEvento,
|
||||||
|
onAlerta,
|
||||||
|
onEtaUpdate,
|
||||||
|
autoReconnect = true,
|
||||||
|
reconnectInterval = 5000,
|
||||||
|
}: UseTrackingWebSocketOptions): UseTrackingWebSocketReturn {
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const getWebSocketUrl = useCallback(() => {
|
||||||
|
// Build WebSocket URL with subscription parameters
|
||||||
|
const baseUrl = import.meta.env.VITE_WS_URL || 'ws://localhost:3014';
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (viajeId) params.append('viajeId', viajeId);
|
||||||
|
if (unidadIds?.length) params.append('unidadIds', unidadIds.join(','));
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
return `${baseUrl}/ws/tracking${queryString ? `?${queryString}` : ''}`;
|
||||||
|
}, [viajeId, unidadIds]);
|
||||||
|
|
||||||
|
const handleMessage = useCallback((event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const message: WebSocketMessage = JSON.parse(event.data);
|
||||||
|
setLastMessage(message);
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case 'POSICION':
|
||||||
|
onPosicion?.(message.payload as PosicionActual);
|
||||||
|
break;
|
||||||
|
case 'EVENTO':
|
||||||
|
onEvento?.(message.payload as EventoTracking);
|
||||||
|
break;
|
||||||
|
case 'ALERTA':
|
||||||
|
onAlerta?.(message.payload as { tipo: string; mensaje: string; viajeId?: string });
|
||||||
|
break;
|
||||||
|
case 'ETA_UPDATE':
|
||||||
|
onEtaUpdate?.(message.payload as { viajeId: string; nuevoEta: string; motivo: string });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error parsing WebSocket message:', err);
|
||||||
|
}
|
||||||
|
}, [onPosicion, onEvento, onAlerta, onEtaUpdate]);
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
// Clear any existing reconnect timeout
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
reconnectTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close existing connection if any
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = getWebSocketUrl();
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setIsConnected(true);
|
||||||
|
setError(null);
|
||||||
|
console.log('[WebSocket] Connected to tracking');
|
||||||
|
|
||||||
|
// Send subscription message
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'SUBSCRIBE',
|
||||||
|
payload: {
|
||||||
|
viajeId,
|
||||||
|
unidadIds,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = handleMessage;
|
||||||
|
|
||||||
|
ws.onerror = (event) => {
|
||||||
|
console.error('[WebSocket] Error:', event);
|
||||||
|
setError(new Error('WebSocket connection error'));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = (event) => {
|
||||||
|
setIsConnected(false);
|
||||||
|
console.log('[WebSocket] Disconnected:', event.reason);
|
||||||
|
|
||||||
|
// Auto-reconnect if enabled
|
||||||
|
if (autoReconnect && !event.wasClean) {
|
||||||
|
reconnectTimeoutRef.current = setTimeout(() => {
|
||||||
|
console.log('[WebSocket] Attempting to reconnect...');
|
||||||
|
connect();
|
||||||
|
}, reconnectInterval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
wsRef.current = ws;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err as Error);
|
||||||
|
console.error('[WebSocket] Connection error:', err);
|
||||||
|
}
|
||||||
|
}, [getWebSocketUrl, handleMessage, viajeId, unidadIds, autoReconnect, reconnectInterval]);
|
||||||
|
|
||||||
|
const disconnect = useCallback(() => {
|
||||||
|
// Clear reconnect timeout
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
reconnectTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close WebSocket
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close(1000, 'User disconnected');
|
||||||
|
wsRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsConnected(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Connect on mount, disconnect on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disconnect();
|
||||||
|
};
|
||||||
|
}, [viajeId, unidadIds?.join(',')]); // Reconnect when subscription changes
|
||||||
|
|
||||||
|
return {
|
||||||
|
isConnected,
|
||||||
|
lastMessage,
|
||||||
|
error,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper hook for simpler usage - just positions
|
||||||
|
export function useTrackingPositions(unidadIds?: string[]) {
|
||||||
|
const [posiciones, setPosiciones] = useState<Map<string, PosicionActual>>(new Map());
|
||||||
|
|
||||||
|
const { isConnected, error } = useTrackingWebSocket({
|
||||||
|
unidadIds,
|
||||||
|
onPosicion: (posicion) => {
|
||||||
|
setPosiciones((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(posicion.unidadId, posicion);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
posiciones: Array.from(posiciones.values()),
|
||||||
|
isConnected,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper hook for viaje tracking
|
||||||
|
export function useViajeTracking(viajeId: string) {
|
||||||
|
const [eventos, setEventos] = useState<EventoTracking[]>([]);
|
||||||
|
const [posicionActual, setPosicionActual] = useState<PosicionActual | null>(null);
|
||||||
|
const [eta, setEta] = useState<string | null>(null);
|
||||||
|
const [alertas, setAlertas] = useState<Array<{ tipo: string; mensaje: string }>>([]);
|
||||||
|
|
||||||
|
const { isConnected, error } = useTrackingWebSocket({
|
||||||
|
viajeId,
|
||||||
|
onPosicion: (posicion) => {
|
||||||
|
setPosicionActual(posicion);
|
||||||
|
},
|
||||||
|
onEvento: (evento) => {
|
||||||
|
setEventos((prev) => [evento, ...prev]);
|
||||||
|
},
|
||||||
|
onEtaUpdate: (data) => {
|
||||||
|
setEta(data.nuevoEta);
|
||||||
|
},
|
||||||
|
onAlerta: (alerta) => {
|
||||||
|
setAlertas((prev) => [alerta, ...prev.slice(0, 9)]); // Keep last 10
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearAlertas = useCallback(() => {
|
||||||
|
setAlertas([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventos,
|
||||||
|
posicionActual,
|
||||||
|
eta,
|
||||||
|
alertas,
|
||||||
|
clearAlertas,
|
||||||
|
isConnected,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -8,3 +8,13 @@ export { trackingApi } from './api/tracking.api';
|
|||||||
export { TrackingMap } from './components/TrackingMap';
|
export { TrackingMap } from './components/TrackingMap';
|
||||||
export { EventosList } from './components/EventosList';
|
export { EventosList } from './components/EventosList';
|
||||||
export { GeocercasList } from './components/GeocercasList';
|
export { GeocercasList } from './components/GeocercasList';
|
||||||
|
export { ViajeTrackingView } from './components/ViajeTrackingView';
|
||||||
|
export { ETAProgressBar } from './components/ETAProgressBar';
|
||||||
|
export { EventTimeline } from './components/EventTimeline';
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
export {
|
||||||
|
useTrackingWebSocket,
|
||||||
|
useTrackingPositions,
|
||||||
|
useViajeTracking,
|
||||||
|
} from './hooks';
|
||||||
|
|||||||
245
src/pages/DespachoPage.tsx
Normal file
245
src/pages/DespachoPage.tsx
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
/**
|
||||||
|
* Despacho Page
|
||||||
|
* ERP Transportistas
|
||||||
|
* Sprint S6 - TASK-007
|
||||||
|
*
|
||||||
|
* Main dispatch board page with map and panels.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
despachoApi,
|
||||||
|
DispatchMap,
|
||||||
|
ViajesPendientesPanel,
|
||||||
|
UnidadStatusPanel,
|
||||||
|
AsignacionModal,
|
||||||
|
EstadoViajeDespacho,
|
||||||
|
type ViajePendiente,
|
||||||
|
type EstadoUnidadDespacho,
|
||||||
|
} from '@/features/despacho';
|
||||||
|
|
||||||
|
export default function DespachoPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Selection state
|
||||||
|
const [selectedUnidad, setSelectedUnidad] = useState<string | null>(null);
|
||||||
|
const [selectedViaje, setSelectedViaje] = useState<string | null>(null);
|
||||||
|
const [showAsignacionModal, setShowAsignacionModal] = useState(false);
|
||||||
|
const [viajeParaAsignar, setViajeParaAsignar] = useState<ViajePendiente | null>(null);
|
||||||
|
|
||||||
|
// Fetch unit statuses
|
||||||
|
const {
|
||||||
|
data: estadosData,
|
||||||
|
isLoading: loadingEstados,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['despacho-estados'],
|
||||||
|
queryFn: () => despachoApi.getEstadosUnidad(),
|
||||||
|
refetchInterval: 30000, // Refresh every 30s
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch pending trips
|
||||||
|
const {
|
||||||
|
data: viajesData,
|
||||||
|
isLoading: loadingViajes,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['despacho-viajes-pendientes'],
|
||||||
|
queryFn: () => despachoApi.getViajesPendientes({ estado: EstadoViajeDespacho.PENDIENTE }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch suggestions when assigning
|
||||||
|
const {
|
||||||
|
data: sugerenciasData,
|
||||||
|
isLoading: loadingSugerencias,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['despacho-sugerencias', viajeParaAsignar?.id],
|
||||||
|
queryFn: () => despachoApi.getSugerencias(viajeParaAsignar!.id),
|
||||||
|
enabled: !!viajeParaAsignar,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const asignarMutation = useMutation({
|
||||||
|
mutationFn: (data: { viajeId: string; unidadId: string; operadorId: string }) =>
|
||||||
|
despachoApi.asignarViaje(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['despacho-estados'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['despacho-viajes-pendientes'] });
|
||||||
|
setShowAsignacionModal(false);
|
||||||
|
setViajeParaAsignar(null);
|
||||||
|
setSelectedViaje(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const actualizarEstadoMutation = useMutation({
|
||||||
|
mutationFn: (data: { unidadId: string; estado: EstadoUnidadDespacho }) =>
|
||||||
|
despachoApi.actualizarEstadoUnidad(data.unidadId, { estado: data.estado }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['despacho-estados'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const liberarMutation = useMutation({
|
||||||
|
mutationFn: (unidadId: string) => despachoApi.liberarUnidad(unidadId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['despacho-estados'] });
|
||||||
|
setSelectedUnidad(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sincronizarGpsMutation = useMutation({
|
||||||
|
mutationFn: () => despachoApi.sincronizarGps('default'),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['despacho-estados'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Data
|
||||||
|
const unidades = estadosData?.data || [];
|
||||||
|
const viajes = viajesData?.data || [];
|
||||||
|
const sugerencias = sugerenciasData?.data || [];
|
||||||
|
|
||||||
|
// Selected unit data
|
||||||
|
const selectedUnidadData = selectedUnidad
|
||||||
|
? unidades.find((u) => u.id === selectedUnidad) || null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleAsignarClick = useCallback((viaje: ViajePendiente) => {
|
||||||
|
setViajeParaAsignar(viaje);
|
||||||
|
setShowAsignacionModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAsignar = useCallback(
|
||||||
|
(viajeId: string, unidadId: string, operadorId: string) => {
|
||||||
|
asignarMutation.mutate({ viajeId, unidadId, operadorId });
|
||||||
|
},
|
||||||
|
[asignarMutation]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleActualizarEstado = useCallback(
|
||||||
|
(unidadId: string, estado: EstadoUnidadDespacho) => {
|
||||||
|
actualizarEstadoMutation.mutate({ unidadId, estado });
|
||||||
|
},
|
||||||
|
[actualizarEstadoMutation]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleLiberarUnidad = useCallback(
|
||||||
|
(unidadId: string) => {
|
||||||
|
liberarMutation.mutate(unidadId);
|
||||||
|
},
|
||||||
|
[liberarMutation]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-[calc(100vh-8rem)]">
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Centro de Despacho</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
Asigna viajes a unidades y operadores disponibles
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => sincronizarGpsMutation.mutate()}
|
||||||
|
disabled={sincronizarGpsMutation.isPending}
|
||||||
|
className="flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Sincronizar GPS
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{unidades.filter((u) => u.estado === 'DISPONIBLE').length} disponibles
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main layout */}
|
||||||
|
<div className="flex h-[calc(100%-4rem)] gap-4">
|
||||||
|
{/* Left panel - Pending trips */}
|
||||||
|
<div className="w-80 flex-shrink-0">
|
||||||
|
<ViajesPendientesPanel
|
||||||
|
viajes={viajes}
|
||||||
|
selectedViaje={selectedViaje}
|
||||||
|
onSelectViaje={setSelectedViaje}
|
||||||
|
onAsignar={handleAsignarClick}
|
||||||
|
loading={loadingViajes}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center - Map */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<DispatchMap
|
||||||
|
unidades={unidades}
|
||||||
|
viajes={viajes}
|
||||||
|
selectedUnidad={selectedUnidad}
|
||||||
|
selectedViaje={selectedViaje}
|
||||||
|
onSelectUnidad={setSelectedUnidad}
|
||||||
|
onSelectViaje={setSelectedViaje}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right panel - Unit details (conditional) */}
|
||||||
|
{selectedUnidadData && (
|
||||||
|
<div className="w-80 flex-shrink-0">
|
||||||
|
<UnidadStatusPanel
|
||||||
|
unidad={selectedUnidadData}
|
||||||
|
onClose={() => setSelectedUnidad(null)}
|
||||||
|
onLiberarUnidad={handleLiberarUnidad}
|
||||||
|
onActualizarEstado={handleActualizarEstado}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assignment modal */}
|
||||||
|
{showAsignacionModal && viajeParaAsignar && (
|
||||||
|
<AsignacionModal
|
||||||
|
viaje={viajeParaAsignar}
|
||||||
|
sugerencias={sugerencias}
|
||||||
|
loading={loadingSugerencias}
|
||||||
|
onAsignar={handleAsignar}
|
||||||
|
onClose={() => {
|
||||||
|
setShowAsignacionModal(false);
|
||||||
|
setViajeParaAsignar(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading overlay */}
|
||||||
|
{(loadingEstados || asignarMutation.isPending) && (
|
||||||
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/20">
|
||||||
|
<div className="rounded-lg bg-white px-6 py-4 shadow-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<svg className="h-5 w-5 animate-spin text-primary-600" viewBox="0 0 24 24">
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-gray-700">Procesando...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user