913 lines
28 KiB
Vue

<script setup>
import { ref, reactive, onMounted, computed, watch } from "vue";
import { Loader } from "@googlemaps/js-api-loader";
const props = defineProps({
counters: {
type: Object,
required: true,
},
});
const mapContainer = ref(null);
const loading = ref(true);
const error = ref(null);
const map = ref(null);
const markers = ref([]);
const geocoder = ref(null);
// Filtros activos
const filters = reactive({
sin_avances: true,
abiertas: true,
finalizadas: true,
});
// Configuración de colores por estado
const markerColors = {
sin_avances: "#EF4444", // Rojo
abiertas: "#F59E0B", // Amarillo
finalizadas: "#10B981", // Verde
};
// Estadísticas de procesamiento
const processingStats = ref({
total: 0,
successful: 0,
failed: 0,
outOfBounds: 0,
});
// Contador de marcadores visibles
const visibleMarkersCount = computed(() => {
return markers.value.filter((marker) => marker.visible).length;
});
// Coordenadas del centro de Comalcalco, Tabasco
const COMALCALCO_CENTER = {
lat: 18.26,
lng: -93.25,
};
// Límites expandidos de Tabasco (más amplios para capturar todas las obras)
const TABASCO_BOUNDS = {
north: 18.7, // Expandido hacia el norte
south: 17.3, // Expandido hacia el sur
east: -92.0, // Expandido hacia el este
west: -94.7, // Expandido hacia el oeste
};
// Cache para geocodificación (evitar llamadas duplicadas)
const geocodingCache = new Map();
const searchQuery = ref('');
const searchResults = ref([]);
const showSearchResults = ref(false);
const searchLoading = ref(false);
const searchAddresses = async () => {
if (!searchQuery.value.trim()) {
searchResults.value = [];
showSearchResults.value = false;
return;
}
searchLoading.value = true;
const query = searchQuery.value.toLowerCase();
// Buscar en las obras existentes
const allObras = [
...props.counters.sin_avances.detalles.map((obra) => ({
...obra,
estado: "sin_avances",
})),
...props.counters.abiertas.detalles.map((obra) => ({
...obra,
estado: "abiertas",
})),
...props.counters.finalizadas.detalles.map((obra) => ({
...obra,
estado: "finalizadas",
})),
];
// Filtrar obras que coincidan con la búsqueda
const matches = allObras.filter(obra =>
obra.direccion_obra?.toLowerCase().includes(query) ||
obra.num_proyecto?.toLowerCase().includes(query)
);
searchResults.value = matches.slice(0, 10); // Limitar a 10 resultados
showSearchResults.value = true;
searchLoading.value = false;
};
//Función para ir a una obra específica
const goToObra = async (obra) => {
searchQuery.value = '';
searchResults.value = [];
showSearchResults.value = false;
// Buscar el marcador correspondiente
const markerData = markers.value.find(m =>
m.obras.some(o =>
o.num_proyecto === obra.num_proyecto &&
o.direccion_obra === obra.direccion_obra
)
);
if (markerData) {
// Centrar el mapa en el marcador
map.value.setCenter(markerData.marker.getPosition());
map.value.setZoom(16);
// Abrir el InfoWindow
markers.value.forEach((m) => m.infoWindow?.close());
markerData.infoWindow.open(map.value, markerData.marker);
}
};
// Limpiar resultados de búsqueda
const clearSearch = () => {
searchQuery.value = '';
searchResults.value = [];
showSearchResults.value = false;
};
const initMap = async () => {
loading.value = true;
error.value = null;
try {
const loader = new Loader({
apiKey: import.meta.env.VITE_GOOGLE_MAPS_API_KEY,
version: "weekly",
libraries: ["places", "geometry"],
});
const google = await loader.load();
if (!google.maps) {
throw new Error("Google Maps no se cargó correctamente");
}
// Inicializar el mapa con zoom más amplio
map.value = new google.maps.Map(mapContainer.value, {
center: COMALCALCO_CENTER,
zoom: 11, // Zoom más amplio para ver toda la región
restriction: {
latLngBounds: TABASCO_BOUNDS,
strictBounds: false,
},
mapTypeControl: true,
streetViewControl: true,
fullscreenControl: true,
zoomControl: true,
});
geocoder.value = new google.maps.Geocoder();
await loadMarkers();
} catch (err) {
console.error("Error loading Google Maps:", err);
error.value = err.message || "Error al cargar Google Maps";
} finally {
loading.value = false;
}
};
const loadMarkers = async () => {
if (!map.value || !geocoder.value) return;
// Limpiar marcadores existentes
clearMarkers();
// Resetear estadísticas
processingStats.value = {
total: 0,
successful: 0,
failed: 0,
outOfBounds: 0,
duplicates: 0,
};
const allObras = [
...props.counters.sin_avances.detalles.map((obra) => ({
...obra,
estado: "sin_avances",
})),
...props.counters.abiertas.detalles.map((obra) => ({
...obra,
estado: "abiertas",
})),
...props.counters.finalizadas.detalles.map((obra) => ({
...obra,
estado: "finalizadas",
})),
];
processingStats.value.total = allObras.length;
console.table(
allObras.map((obra) => ({
proyecto: obra.num_proyecto,
direccion: obra.direccion_obra,
estado: obra.estado,
}))
);
// Agrupar obras por grupo
const obrasPorGrupo = new Map();
allObras.forEach(obra => {
const clave = `${obra.direccion_obra}|${obra.estado}`;
if(!obrasPorGrupo.has(clave)){
obrasPorGrupo.set(clave, {
direccion: obra.direccion_obra,
estado: obra.estado,
obras: []
});
}
obrasPorGrupo.get(clave).obras.push(obra);
});
processingStats.value.total = allObras.length;
// Procesar grupos de obras por dirección en lotes
const grupos = Array.from(obrasPorGrupo.values());
const batchSize = 5;
for (let i = 0; i < grupos.length; i += batchSize) {
const batch = grupos.slice(i, i + batchSize);
const batchPromises = batch.map(grupo =>
createMarkerForGroup(grupo)
);
await Promise.allSettled(batchPromises);
if (i + batchSize < grupos.length) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
updateMarkers();
};
const createMarkerForGroup = async (grupo) => {
const { direccion, obras: obrasEnGrupo } = grupo;
if (!direccion || !geocoder.value) {
processingStats.value.failed += obrasEnGrupo.length;
return;
}
// Normalización corregida de direcciones
let direccionNormalizada = direccion
.replace(/^RA\.\s*/i, "Ranchería ")
.replace(/CIUDAD\s+/i, "")
.replace(/2DA\s+SECCION/gi, "2 sección")
.replace(/\s+/g, " ")
.replace(/,\s*,/g, ",")
.trim();
// Construir direcciones alternativas para geocodificación
const addresses = [
`${direccionNormalizada}, Comalcalco, Tabasco, México`,
`${direccion}, Comalcalco, Tabasco, México`,
`${direccionNormalizada.replace('Ranchería ', '')}, Comalcalco, Tabasco, México`,
`${direccion.replace('RA. ', '')}, Comalcalco, Tabasco`,
`Comalcalco, Tabasco, México`
];
// Verificar cache primero
const cacheKey = direccion;
if (geocodingCache.has(cacheKey)) {
const cachedData = geocodingCache.get(cacheKey);
processingStats.value.duplicates += obrasEnGrupo.length - 1;
return createMarkerFromPosition(grupo, cachedData.position, cachedData.formatted_address);
}
// Intentar geocodificación con direcciones alternativas
for (let i = 0; i < addresses.length; i++) {
const address = addresses[i];
try {
const result = await geocodeAddress(address);
if (result.success) {
// Guardar en cache
geocodingCache.set(cacheKey, {
position: result.position,
formatted_address: result.formatted_address,
});
return createMarkerFromPosition(grupo, result.position, result.formatted_address);
}
} catch (err) {
console.warn(`Error en intento ${i + 1}: ${address}`, err);
}
}
// Si todas las direcciones fallan, crear marcador fallback
processingStats.value.failed += obrasEnGrupo.length;
// Crear marcador fallback con offset aleatorio
const randomOffset = {
lat: (Math.random() - 0.5) * 0.02,
lng: (Math.random() - 0.5) * 0.02,
};
const fallbackPosition = {
lat: COMALCALCO_CENTER.lat + randomOffset.lat,
lng: COMALCALCO_CENTER.lng + randomOffset.lng,
};
return createMarkerFromPosition(grupo, fallbackPosition, "Ubicación aproximada - Comalcalco, Tabasco");
};
const geocodeAddress = (address) => {
return new Promise((resolve) => {
const request = {
address: address,
region: 'mx',
bounds: new google.maps.LatLngBounds(
new google.maps.LatLng(18.1, -93.4),
new google.maps.LatLng(18.4, -93.0)
),
};
geocoder.value.geocode(request, (results, status) => {
if (status === "OK" && results && results.length > 0) {
let bestResult = null;
for (let result of results) {
const formatted = result.formatted_address.toLowerCase();
// Priorizar resultados que mencionen Comalcalco
if (formatted.includes('comalcalco')) {
bestResult = result;
break;
}
// Si no encuentra Comalcalco, verificar distancia
if (!bestResult) {
const position = result.geometry.location;
const comalcalcoCenter = new google.maps.LatLng(COMALCALCO_CENTER.lat, COMALCALCO_CENTER.lng);
const distance = google.maps.geometry.spherical.computeDistanceBetween(position, comalcalcoCenter);
// Si está dentro de 50km de Comalcalco, considerarlo válido
if (distance <= 50000) {
bestResult = result;
}
}
}
if (bestResult) {
const position = bestResult.geometry.location;
const lat = position.lat();
const lng = position.lng();
resolve({
success: true,
position: { lat, lng },
formatted_address: bestResult.formatted_address,
});
} else {
console.warn(`No se encontró resultado válido para "${address}"`);
resolve({
success: false,
status: 'NO_VALID_RESULT',
address,
});
}
} else {
console.warn(`Geocodificación falló para "${address}": ${status}`);
resolve({
success: false,
status,
address,
});
}
});
});
};
const createMarkerFromPosition = (grupo, position, formatted_address) => {
const { obras: obrasEnPosicion, estado } = grupo;
const statusOffsets = {
sin_avances: { lat: 0.00008, lng: -0.00005 }, // Arriba-Izquierda
abiertas: { lat: 0, lng: 0.0001 }, // Derecha
finalizadas: { lat: -0.00004,lng: -0.00005 }, // Abajo-Izquierda
};
const offset = statusOffsets[estado] || { lat: 0, lng: 0 };
const lat = position.lat + offset.lat;
const lng = position.lng + offset.lng;
// Usar la primera obra para definir el marcador principal
const obraPrincipal = obrasEnPosicion[0];
const marker = new google.maps.Marker({
position: new google.maps.LatLng(lat, lng),
map: map.value,
title: obrasEnPosicion.length > 1
? `${obrasEnPosicion.length} obras en ${obraPrincipal.direccion_obra} (${estado.replace('_', ' ')})`
: `${obraPrincipal.num_proyecto} - ${obraPrincipal.direccion_obra}`,
icon: createCustomMarker(estado), // El color del marcador depende del estado del grupo.
visible: filters[estado],
animation: google.maps.Animation.DROP,
});
// Info window con todas las obras en esa posición
const infoWindow = new google.maps.InfoWindow({
content: createMultipleInfoWindowContent(obrasEnPosicion, formatted_address),
});
marker.addListener("click", () => {
markers.value.forEach((m) => m.infoWindow?.close());
infoWindow.open(map.value, marker);
});
markers.value.push({
marker,
infoWindow,
estado: obraPrincipal.estado,
obras: obrasEnPosicion,
visible: filters[obraPrincipal.estado],
position: { lat, lng },
});
processingStats.value.successful += obrasEnPosicion.length;
};
const createCustomMarker = (estado) => {
const color = markerColors[estado];
return {
url: `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
<svg width="24" height="36" viewBox="0 0 24 36" xmlns="http://www.w3.org/2000/svg">
<path d="M12 0C5.373 0 0 5.373 0 12c0 9 12 24 12 24s12-15 12-24c0-6.627-5.373-12-12-12z" fill="${color}"/>
<circle cx="12" cy="12" r="6" fill="white"/>
<circle cx="12" cy="12" r="3" fill="${color}"/>
</svg>
`)}`,
scaledSize: new google.maps.Size(24, 36),
anchor: new google.maps.Point(12, 36)
};
};
const createMultipleInfoWindowContent = (obras, formatted_address = null) => {
if (obras.length === 1) {
return createInfoWindowContent(obras[0], formatted_address);
}
const direccion = obras[0].direccion_obra;
let obrasHtml = '';
obras.forEach(obra => {
const estadoConfig = {
sin_avances: { color: "bg-red-100 text-red-800", label: "Sin avances" },
abiertas: { color: "bg-yellow-100 text-yellow-800", label: "En proceso" },
finalizadas: { color: "bg-green-100 text-green-800", label: "Finalizada" },
};
const config = estadoConfig[obra.estado];
const cumplimiento = obra.cumplimiento_total ? `${Number(obra.cumplimiento_total).toFixed(1)}%` : "N/A";
obrasHtml += `
<div class="border-b border-gray-200 pb-3 mb-3 last:border-b-0 last:pb-0 last:mb-0">
<div class="flex items-center justify-between mb-2">
<h4 class="font-semibold text-gray-900">${obra.num_proyecto || "N/A"}</h4>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.color}">
${config.label}
</span>
</div>
<div class="flex items-center space-x-2">
<div class="flex-1 bg-gray-200 rounded-full h-1.5">
<div class="h-1.5 rounded-full transition-all duration-300 ${
obra.estado === "finalizadas" ? "bg-green-500" : obra.estado === "abiertas" ? "bg-yellow-500" : "bg-red-500"
}" style="width: ${Number(obra.cumplimiento_total) || 0}%"></div>
</div>
<span class="text-xs font-medium text-gray-700 min-w-[35px]">${cumplimiento}</span>
</div>
</div>
`;
});
return `
<div class="p-4 max-w-sm">
<div class="mb-3">
<h3 class="font-bold text-gray-900 text-lg mb-1">${obras.length} Obras en esta ubicación</h3>
<p class="text-xs text-gray-600">${direccion}</p>
${formatted_address ? `<p class="text-xs text-gray-500 mt-1">${formatted_address}</p>` : ''}
</div>
<div class="space-y-3 text-sm max-h-60 overflow-y-auto">
${obrasHtml}
</div>
</div>
`;
};
const createInfoWindowContent = (obra, formatted_address = null) => {
const estadoConfig = {
sin_avances: { color: "bg-red-100 text-red-800", label: "Sin avances" },
abiertas: { color: "bg-yellow-100 text-yellow-800", label: "En proceso" },
finalizadas: { color: "bg-green-100 text-green-800", label: "Finalizada" },
};
const config = estadoConfig[obra.estado];
const cumplimiento = obra.cumplimiento_total
? `${Number(obra.cumplimiento_total).toFixed(1)}%`
: "N/A";
return `
<div class="p-4 max-w-sm">
<div class="flex items-center justify-between mb-3">
<h3 class="font-bold text-gray-900 text-lg">${
obra.num_proyecto || "N/A"
}</h3>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
config.color
}">
${config.label}
</span>
</div>
<div class="space-y-3 text-sm">
<div>
<p class="font-medium text-gray-700 mb-1">Dirección Original:</p>
<p class="text-gray-600 text-xs">${obra.direccion_obra || "N/A"}</p>
</div>
${
formatted_address
? `
<div>
<p class="font-medium text-gray-700 mb-1">Ubicación Encontrada:</p>
<p class="text-gray-500 text-xs">${formatted_address}</p>
</div>
`
: ""
}
<div>
<p class="font-medium text-gray-700 mb-1">Cumplimiento:</p>
<div class="flex items-center space-x-2">
<div class="flex-1 bg-gray-200 rounded-full h-2">
<div class="h-2 rounded-full transition-all duration-300 ${
obra.estado === "finalizadas"
? "bg-green-500"
: obra.estado === "abiertas"
? "bg-yellow-500"
: "bg-red-500"
}"
style="width: ${
Number(obra.cumplimiento_total) || 0
}%"></div>
</div>
<span class="text-xs font-medium text-gray-700 min-w-[40px]">${cumplimiento}</span>
</div>
</div>
</div>
</div>
`;
};
const updateMarkers = () => {
let visibleCount = 0;
markers.value.forEach(({ marker, estado }) => {
const shouldShow = filters[estado];
marker.setVisible(shouldShow);
if (shouldShow) visibleCount++;
// Actualizar el estado visible del marcador
const markerIndex = markers.value.findIndex((m) => m.marker === marker);
if (markerIndex !== -1) {
markers.value[markerIndex].visible = shouldShow;
}
});
};
const clearMarkers = () => {
markers.value.forEach(({ marker, infoWindow }) => {
infoWindow?.close();
marker.setMap(null);
});
markers.value = [];
geocodingCache.clear(); // Limpiar cache al recargar
};
// Función para ajustar el viewport del mapa a todos los marcadores visibles
const fitMapToMarkers = () => {
if (!map.value || markers.value.length === 0) return;
const bounds = new google.maps.LatLngBounds();
const visibleMarkers = markers.value.filter((m) => m.visible);
if (visibleMarkers.length === 0) return;
visibleMarkers.forEach(({ position }) => {
bounds.extend(new google.maps.LatLng(position.lat, position.lng));
});
map.value.fitBounds(bounds);
// Asegurar zoom mínimo
const listener = google.maps.event.addListener(map.value, "idle", () => {
if (map.value.getZoom() > 15) map.value.setZoom(15);
google.maps.event.removeListener(listener);
});
};
// Recargar marcadores cuando cambien los contadores
watch(
() => props.counters,
() => {
if (map.value) {
loadMarkers();
}
},
{ deep: true }
);
onMounted(() => {
initMap();
});
</script>
<template>
<div class="bg-white rounded-lg shadow-lg overflow-hidden">
<div
class="px-6 py-4 bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200"
>
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-medium text-gray-900">Mapa de Obras</h3>
<p class="text-sm text-gray-600 mt-1">
Ubicación geográfica de proyectos en Comalcalco, Tabasco
</p>
</div>
<!-- Filtros por estado -->
<div class="flex items-center space-x-4">
<!-- NUEVO: Buscador de direcciones -->
<div class="relative">
<div class="flex items-center space-x-2">
<div class="relative">
<input
v-model="searchQuery"
@input="searchAddresses"
@focus="searchAddresses"
type="text"
placeholder="Buscar obra o dirección..."
class="w-64 px-3 py-1 text-sm border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
/>
<div class="absolute inset-y-0 right-0 flex items-center pr-3">
<svg
v-if="searchLoading"
class="animate-spin h-4 w-4 text-gray-400"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="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"
></path>
</svg>
<svg
v-else-if="searchQuery"
@click="clearSearch"
class="h-4 w-4 text-gray-400 cursor-pointer hover:text-gray-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<svg
v-else
class="h-4 w-4 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
</div>
</div>
<!-- NUEVO: Resultados de búsqueda -->
<div
v-if="showSearchResults && searchResults.length > 0"
class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto"
>
<div
v-for="obra in searchResults"
:key="`${obra.num_proyecto}-${obra.direccion_obra}`"
@click="goToObra(obra)"
class="px-3 py-2 hover:bg-gray-50 cursor-pointer border-b border-gray-100 last:border-b-0"
>
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">
{{ obra.num_proyecto || 'N/A' }}
</p>
<p class="text-xs text-gray-500 truncate">
{{ obra.direccion_obra }}
</p>
</div>
<div class="flex-shrink-0 ml-2">
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
:class="{
'bg-red-100 text-red-800': obra.estado === 'sin_avances',
'bg-yellow-100 text-yellow-800': obra.estado === 'abiertas',
'bg-green-100 text-green-800': obra.estado === 'finalizadas'
}"
>
{{
obra.estado === 'sin_avances' ? 'Sin avances' :
obra.estado === 'abiertas' ? 'Abierta' : 'Finalizada'
}}
</span>
</div>
</div>
</div>
</div>
<!-- NUEVO: Mensaje cuando no hay resultados -->
<div
v-else-if="showSearchResults && searchResults.length === 0 && searchQuery.trim()"
class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg"
>
<div class="px-3 py-2 text-sm text-gray-500 text-center">
No se encontraron resultados
</div>
</div>
</div>
<div class="flex items-center space-x-2">
<input
id="sin-avances"
v-model="filters.sin_avances"
@change="updateMarkers"
type="checkbox"
class="rounded border-gray-300 text-red-600 focus:ring-red-500"
/>
<label
for="sin-avances"
class="flex items-center text-sm text-gray-700"
>
<div class="w-3 h-3 bg-red-500 rounded-full mr-2"></div>
Sin avances ({{ counters.sin_avances.count }})
</label>
</div>
<div class="flex items-center space-x-2">
<input
id="abiertas"
v-model="filters.abiertas"
@change="updateMarkers"
type="checkbox"
class="rounded border-gray-300 text-yellow-600 focus:ring-yellow-500"
/>
<label
for="abiertas"
class="flex items-center text-sm text-gray-700"
>
<div class="w-3 h-3 bg-yellow-500 rounded-full mr-2"></div>
Abiertas ({{ counters.abiertas.count }})
</label>
</div>
<div class="flex items-center space-x-2">
<input
id="finalizadas"
v-model="filters.finalizadas"
@change="updateMarkers"
type="checkbox"
class="rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
<label
for="finalizadas"
class="flex items-center text-sm text-gray-700"
>
<div class="w-3 h-3 bg-green-500 rounded-full mr-2"></div>
Finalizadas ({{ counters.finalizadas.count }})
</label>
</div>
</div>
</div>
<!-- Controles adicionales -->
<div class="flex items-center justify-between mt-3">
<div class="text-sm text-gray-600">
Marcadores visibles: {{ visibleMarkersCount }} de {{ markers.length }}
</div>
<div class="flex items-center space-x-2">
<button
@click="fitMapToMarkers"
class="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
Ajustar Vista
</button>
<button
@click="loadMarkers"
class="px-3 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
>
Recargar
</button>
</div>
</div>
</div>
<!-- Mapa -->
<div ref="mapContainer" class="w-full h-96 relative">
<div
v-if="loading"
class="absolute inset-0 bg-gray-100 flex items-center justify-center z-10"
>
<div class="text-center">
<div
class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"
></div>
<p class="mt-2 text-sm text-gray-600">
Cargando mapa y geocodificando direcciones...
</p>
<p class="mt-1 text-xs text-gray-500">
Esto puede tomar unos segundos
</p>
</div>
</div>
<div
v-if="error"
class="absolute inset-0 bg-gray-100 flex items-center justify-center z-10"
>
<div class="text-center">
<svg
class="mx-auto h-12 w-12 text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">
Error al cargar el mapa
</h3>
<p class="mt-1 text-sm text-gray-500">{{ error }}</p>
<button
@click="initMap"
class="mt-4 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 transition-colors"
>
Reintentar
</button>
</div>
</div>
</div>
<!-- Leyenda mejorada -->
<div class="px-6 py-4 bg-gray-50 border-t border-gray-200">
<div class="flex items-center justify-between text-xs text-gray-600">
<div class="flex items-center space-x-4">
<span class="flex items-center">
<div class="w-2 h-2 bg-red-500 rounded-full mr-1"></div>
Sin avances
</span>
<span class="flex items-center">
<div class="w-2 h-2 bg-yellow-500 rounded-full mr-1"></div>
En proceso
</span>
<span class="flex items-center">
<div class="w-2 h-2 bg-green-500 rounded-full mr-1"></div>
Finalizadas
</span>
</div>
</div>
</div>
</div>
</template>