[WORKSPACE] feat: Add tracking/despacho features and components

This commit is contained in:
Adrian Flores Cortes 2026-01-29 17:57:11 -06:00
parent f0a09fea29
commit 1de866febe
17 changed files with 2449 additions and 1 deletions

View File

@ -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 />} />

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

@ -0,0 +1,11 @@
/**
* Tracking Hooks Index
* ERP Transportistas
* Sprint S7 - TASK-007
*/
export {
useTrackingWebSocket,
useTrackingPositions,
useViajeTracking,
} from './useTrackingWebSocket';

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

View File

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