[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 TrackingPage = lazy(() => import('./pages/TrackingPage'));
|
||||
const OrdenesTransportePage = lazy(() => import('./pages/OrdenesTransportePage'));
|
||||
const DespachoPage = lazy(() => import('./pages/DespachoPage'));
|
||||
|
||||
// Loading fallback
|
||||
function PageLoader() {
|
||||
@ -26,6 +27,7 @@ function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
<div className="flex items-center gap-8">
|
||||
<h1 className="text-xl font-bold text-primary-600">ERP Transportistas</h1>
|
||||
<nav className="flex gap-4">
|
||||
<a href="/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="/flota" className="text-gray-600 hover:text-gray-900">Flota</a>
|
||||
<a href="/tracking" className="text-gray-600 hover:text-gray-900">Tracking</a>
|
||||
@ -51,7 +53,8 @@ function App() {
|
||||
<MainLayout>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<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="/flota/*" element={<FlotaPage />} />
|
||||
<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 { EventosList } from './EventosList';
|
||||
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 { EventosList } from './components/EventosList';
|
||||
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