Correción Obras
This commit is contained in:
parent
c1c86ef802
commit
885e5183a8
@ -1,10 +1,572 @@
|
|||||||
|
<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.2669,
|
||||||
|
lng: -93.2072,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 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"],
|
||||||
|
});
|
||||||
|
|
||||||
|
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 dirección
|
||||||
|
const obrasPorDireccion = new Map();
|
||||||
|
allObras.forEach(obra => {
|
||||||
|
const direccion = obra.direccion_obra;
|
||||||
|
if (!obrasPorDireccion.has(direccion)) {
|
||||||
|
obrasPorDireccion.set(direccion, []);
|
||||||
|
}
|
||||||
|
obrasPorDireccion.get(direccion).push(obra);
|
||||||
|
});
|
||||||
|
|
||||||
|
processingStats.value.total = allObras.length;
|
||||||
|
|
||||||
|
// Procesar grupos de obras por dirección en lotes
|
||||||
|
const direcciones = Array.from(obrasPorDireccion.keys());
|
||||||
|
const batchSize = 5;
|
||||||
|
|
||||||
|
for (let i = 0; i < direcciones.length; i += batchSize) {
|
||||||
|
const batch = direcciones.slice(i, i + batchSize);
|
||||||
|
const batchPromises = batch.map(direccion =>
|
||||||
|
createMarkerForDireccion(direccion, obrasPorDireccion.get(direccion))
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.allSettled(batchPromises);
|
||||||
|
|
||||||
|
if (i + batchSize < direcciones.length) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateMarkers();
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMarkerForDireccion = async (direccion, obrasEnDireccion) => {
|
||||||
|
if (!direccion || !geocoder.value) {
|
||||||
|
obrasEnDireccion.forEach(() => processingStats.value.failed++);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construir direcciones alternativas para geocodificación
|
||||||
|
const addresses = [
|
||||||
|
`${direccion}, Comalcalco, Tabasco, México`,
|
||||||
|
`${direccion}, Tabasco, México`,
|
||||||
|
`${direccion}, México`,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Verificar cache primero
|
||||||
|
const cacheKey = direccion;
|
||||||
|
if (geocodingCache.has(cacheKey)) {
|
||||||
|
const cachedData = geocodingCache.get(cacheKey);
|
||||||
|
processingStats.value.duplicates += obrasEnDireccion.length - 1;
|
||||||
|
return createMarkerFromPosition(obrasEnDireccion, cachedData.position, cachedData.formatted_address);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intentar geocodificación con direcciones alternativas
|
||||||
|
for (const address of addresses) {
|
||||||
|
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(obrasEnDireccion, result.position, result.formatted_address);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Error geocodificando ${address}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si todas las direcciones fallan, crear marcador fallback
|
||||||
|
processingStats.value.failed += obrasEnDireccion.length;
|
||||||
|
|
||||||
|
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(obrasEnDireccion, fallbackPosition, "Ubicación aproximada - Comalcalco, Tabasco");
|
||||||
|
};
|
||||||
|
|
||||||
|
const geocodeAddress = (address) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
geocoder.value.geocode({ address }, (results, status) => {
|
||||||
|
if (status === "OK" && results[0]) {
|
||||||
|
const position = results[0].geometry.location;
|
||||||
|
const lat = position.lat();
|
||||||
|
const lng = position.lng();
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
position: { lat, lng },
|
||||||
|
formatted_address: results[0].formatted_address,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
status,
|
||||||
|
address,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMarkerFromPosition = (obrasEnPosicion, position, formatted_address) => {
|
||||||
|
const lat = position.lat;
|
||||||
|
const lng = position.lng;
|
||||||
|
|
||||||
|
// Verificar límites expandidos
|
||||||
|
if (
|
||||||
|
lat < TABASCO_BOUNDS.south ||
|
||||||
|
lat > TABASCO_BOUNDS.north ||
|
||||||
|
lng < TABASCO_BOUNDS.west ||
|
||||||
|
lng > TABASCO_BOUNDS.east
|
||||||
|
) {
|
||||||
|
processingStats.value.outOfBounds += obrasEnPosicion.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
? `${obraPrincipal.num_proyecto} - ${obraPrincipal.direccion_obra}`
|
||||||
|
: `${obrasEnPosicion.length} obras en ${obraPrincipal.direccion_obra}`,
|
||||||
|
icon: createCustomMarker(obraPrincipal.estado),
|
||||||
|
visible: filters[obraPrincipal.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];
|
||||||
|
const width = 32;
|
||||||
|
const height = 48;
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
|
||||||
|
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<!-- Sombra para el marcador -->
|
||||||
|
<filter id="dropshadow-${estado}" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-color="rgba(0,0,0,0.3)" flood-opacity="0.5"/>
|
||||||
|
</filter>
|
||||||
|
|
||||||
|
<!-- Gradiente para dar volumen -->
|
||||||
|
<radialGradient id="gradient-${estado}" cx="30%" cy="20%" r="70%">
|
||||||
|
<stop offset="0%" style="stop-color:white;stop-opacity:0.3" />
|
||||||
|
<stop offset="100%" style="stop-color:${color};stop-opacity:1" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Forma principal del marcador (gota/pin) -->
|
||||||
|
<path d="M ${width / 2} ${height - 4}
|
||||||
|
C ${width / 2} ${height - 4},
|
||||||
|
${width * 0.125} ${height * 0.58},
|
||||||
|
${width * 0.125} ${height * 0.375}
|
||||||
|
C ${width * 0.125} ${height * 0.167},
|
||||||
|
${width * 0.292} 2,
|
||||||
|
${width / 2} 2
|
||||||
|
C ${width * 0.708} 2,
|
||||||
|
${width * 0.875} ${height * 0.167},
|
||||||
|
${width * 0.875} ${height * 0.375}
|
||||||
|
C ${width * 0.875} ${height * 0.58},
|
||||||
|
${width / 2} ${height - 4},
|
||||||
|
${width / 2} ${height - 4} Z"
|
||||||
|
fill="url(#gradient-${estado})"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="1.5"
|
||||||
|
filter="url(#dropshadow-${estado})"/>
|
||||||
|
|
||||||
|
<!-- Borde interior más oscuro -->
|
||||||
|
<path d="M ${width / 2} ${height - 4}
|
||||||
|
C ${width / 2} ${height - 4},
|
||||||
|
${width * 0.125} ${height * 0.58},
|
||||||
|
${width * 0.125} ${height * 0.375}
|
||||||
|
C ${width * 0.125} ${height * 0.167},
|
||||||
|
${width * 0.292} 2,
|
||||||
|
${width / 2} 2
|
||||||
|
C ${width * 0.708} 2,
|
||||||
|
${width * 0.875} ${height * 0.167},
|
||||||
|
${width * 0.875} ${height * 0.375}
|
||||||
|
C ${width * 0.875} ${height * 0.58},
|
||||||
|
${width / 2} ${height - 4},
|
||||||
|
${width / 2} ${height - 4} Z"
|
||||||
|
fill="none"
|
||||||
|
stroke="${color}"
|
||||||
|
stroke-width="1"
|
||||||
|
opacity="0.8"/>
|
||||||
|
|
||||||
|
<!-- Círculo interior blanco -->
|
||||||
|
<circle cx="${width / 2}"
|
||||||
|
cy="${height * 0.375}"
|
||||||
|
r="${width * 0.25}"
|
||||||
|
fill="white"
|
||||||
|
stroke="${color}"
|
||||||
|
stroke-width="1"/>
|
||||||
|
</svg>
|
||||||
|
`)}`,
|
||||||
|
scaledSize: new google.maps.Size(width, height),
|
||||||
|
anchor: new google.maps.Point(width / 2, height - 4),
|
||||||
|
origin: new google.maps.Point(0, 0),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
<template>
|
||||||
<div class="bg-white rounded-lg shadow-lg overflow-hidden">
|
<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="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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-gray-900">Mapa de Obras</h3>
|
<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>
|
<p class="text-sm text-gray-600 mt-1">
|
||||||
|
Ubicación geográfica de proyectos en Comalcalco, Tabasco
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filtros por estado -->
|
<!-- Filtros por estado -->
|
||||||
@ -17,7 +579,10 @@
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="rounded border-gray-300 text-red-600 focus:ring-red-500"
|
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">
|
<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>
|
<div class="w-3 h-3 bg-red-500 rounded-full mr-2"></div>
|
||||||
Sin avances ({{ counters.sin_avances.count }})
|
Sin avances ({{ counters.sin_avances.count }})
|
||||||
</label>
|
</label>
|
||||||
@ -31,7 +596,10 @@
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="rounded border-gray-300 text-yellow-600 focus:ring-yellow-500"
|
class="rounded border-gray-300 text-yellow-600 focus:ring-yellow-500"
|
||||||
/>
|
/>
|
||||||
<label for="abiertas" class="flex items-center text-sm text-gray-700">
|
<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>
|
<div class="w-3 h-3 bg-yellow-500 rounded-full mr-2"></div>
|
||||||
Abiertas ({{ counters.abiertas.count }})
|
Abiertas ({{ counters.abiertas.count }})
|
||||||
</label>
|
</label>
|
||||||
@ -45,39 +613,92 @@
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="rounded border-gray-300 text-green-600 focus:ring-green-500"
|
class="rounded border-gray-300 text-green-600 focus:ring-green-500"
|
||||||
/>
|
/>
|
||||||
<label for="finalizadas" class="flex items-center text-sm text-gray-700">
|
<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>
|
<div class="w-3 h-3 bg-green-500 rounded-full mr-2"></div>
|
||||||
Finalizadas ({{ counters.finalizadas.count }})
|
Finalizadas ({{ counters.finalizadas.count }})
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Mapa -->
|
<!-- Mapa -->
|
||||||
<div ref="mapContainer" class="w-full h-96 relative">
|
<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
|
||||||
|
v-if="loading"
|
||||||
|
class="absolute inset-0 bg-gray-100 flex items-center justify-center z-10"
|
||||||
|
>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
<div
|
||||||
<p class="mt-2 text-sm text-gray-600">Cargando mapa...</p>
|
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>
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="absolute inset-0 bg-gray-100 flex items-center justify-center z-10">
|
<div
|
||||||
|
v-if="error"
|
||||||
|
class="absolute inset-0 bg-gray-100 flex items-center justify-center z-10"
|
||||||
|
>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<svg class="mx-auto h-12 w-12 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg
|
||||||
<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"/>
|
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>
|
</svg>
|
||||||
<h3 class="mt-2 text-sm font-medium text-gray-900">Error al cargar el mapa</h3>
|
<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>
|
<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">
|
<button
|
||||||
|
@click="initMap"
|
||||||
|
class="mt-4 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
Reintentar
|
Reintentar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Leyenda -->
|
<!-- Leyenda mejorada -->
|
||||||
<div class="px-6 py-4 bg-gray-50 border-t border-gray-200">
|
<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 justify-between text-xs text-gray-600">
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
@ -98,270 +719,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<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
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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.2669,
|
|
||||||
lng: -93.2072
|
|
||||||
};
|
|
||||||
|
|
||||||
// Límites aproximados de Comalcalco
|
|
||||||
const COMALCALCO_BOUNDS = {
|
|
||||||
north: 19.35,
|
|
||||||
south: 18.18,
|
|
||||||
east: -93.10,
|
|
||||||
west: -93.30
|
|
||||||
};
|
|
||||||
|
|
||||||
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']
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cargar Google Maps
|
|
||||||
const google = await loader.load();
|
|
||||||
|
|
||||||
// Verificar que google.maps esté disponible
|
|
||||||
if (!google.maps) {
|
|
||||||
throw new Error('Google Maps no se cargó correctamente');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inicializar el mapa centrado en Comalcalco
|
|
||||||
map.value = new google.maps.Map(mapContainer.value, {
|
|
||||||
center: COMALCALCO_CENTER,
|
|
||||||
zoom: 12,
|
|
||||||
restriction: {
|
|
||||||
latLngBounds: COMALCALCO_BOUNDS,
|
|
||||||
strictBounds: false
|
|
||||||
},
|
|
||||||
mapTypeControl: true,
|
|
||||||
streetViewControl: true,
|
|
||||||
fullscreenControl: true,
|
|
||||||
zoomControl: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Inicializar el geocodificador
|
|
||||||
geocoder.value = new google.maps.Geocoder();
|
|
||||||
|
|
||||||
// Cargar marcadores de obras
|
|
||||||
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();
|
|
||||||
|
|
||||||
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' }))
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const obra of allObras) {
|
|
||||||
try {
|
|
||||||
await createMarkerForObra(obra);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`Error creating marker for obra ${obra.num_proyecto}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateMarkers();
|
|
||||||
};
|
|
||||||
|
|
||||||
const createMarkerForObra = async (obra) => {
|
|
||||||
if (!obra.direccion_obra || !geocoder.value) return;
|
|
||||||
|
|
||||||
const address = `${obra.direccion_obra}, Comalcalco, Tabasco, México`;
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
geocoder.value.geocode({ address }, (results, status) => {
|
|
||||||
if (status === 'OK' && results[0]) {
|
|
||||||
const position = results[0].geometry.location;
|
|
||||||
|
|
||||||
// Verificar que la ubicación esté dentro de los límites de Comalcalco
|
|
||||||
const lat = position.lat();
|
|
||||||
const lng = position.lng();
|
|
||||||
|
|
||||||
if (lat < COMALCALCO_BOUNDS.south || lat > COMALCALCO_BOUNDS.north ||
|
|
||||||
lng < COMALCALCO_BOUNDS.west || lng > COMALCALCO_BOUNDS.east) {
|
|
||||||
console.warn(`Obra ${obra.num_proyecto} fuera de los límites de Comalcalco`);
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const marker = new google.maps.Marker({
|
|
||||||
position,
|
|
||||||
map: map.value,
|
|
||||||
title: `${obra.num_proyecto} - ${obra.direccion_obra}`,
|
|
||||||
icon: createCustomMarker(obra.estado),
|
|
||||||
visible: filters[obra.estado]
|
|
||||||
});
|
|
||||||
|
|
||||||
// Info window con detalles de la obra
|
|
||||||
const infoWindow = new google.maps.InfoWindow({
|
|
||||||
content: createInfoWindowContent(obra)
|
|
||||||
});
|
|
||||||
|
|
||||||
marker.addListener('click', () => {
|
|
||||||
// Cerrar otras ventanas abiertas
|
|
||||||
markers.value.forEach(m => m.infoWindow?.close());
|
|
||||||
infoWindow.open(map.value, marker);
|
|
||||||
});
|
|
||||||
|
|
||||||
markers.value.push({
|
|
||||||
marker,
|
|
||||||
infoWindow,
|
|
||||||
estado: obra.estado,
|
|
||||||
obra,
|
|
||||||
visible: filters[obra.estado]
|
|
||||||
});
|
|
||||||
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
console.warn(`Geocoding failed for ${address}: ${status}`);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
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 createInfoWindowContent = (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';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="p-4 max-w-sm">
|
|
||||||
<div class="flex items-center justify-between mb-3">
|
|
||||||
<h3 class="font-bold text-gray-900">${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-2 text-sm">
|
|
||||||
<div>
|
|
||||||
<p class="font-medium text-gray-700">Dirección:</p>
|
|
||||||
<p class="text-gray-600">${obra.direccion_obra || 'N/A'}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p class="font-medium text-gray-700">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 ${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">${cumplimiento}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateMarkers = () => {
|
|
||||||
markers.value.forEach(({ marker, estado }) => {
|
|
||||||
const shouldShow = filters[estado];
|
|
||||||
marker.setVisible(shouldShow);
|
|
||||||
|
|
||||||
// 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 = [];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Recargar marcadores cuando cambien los contadores
|
|
||||||
watch(() => props.counters, () => {
|
|
||||||
if (map.value) {
|
|
||||||
loadMarkers();
|
|
||||||
}
|
|
||||||
}, { deep: true });
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
initMap();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|||||||
721
resources/js/Components/Dashboard/MapsCopy.vue
Normal file
721
resources/js/Components/Dashboard/MapsCopy.vue
Normal file
@ -0,0 +1,721 @@
|
|||||||
|
<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.2669,
|
||||||
|
lng: -93.2072,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 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"],
|
||||||
|
});
|
||||||
|
|
||||||
|
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 dirección
|
||||||
|
const obrasPorDireccion = new Map();
|
||||||
|
allObras.forEach(obra => {
|
||||||
|
const direccion = obra.direccion_obra;
|
||||||
|
if (!obrasPorDireccion.has(direccion)) {
|
||||||
|
obrasPorDireccion.set(direccion, []);
|
||||||
|
}
|
||||||
|
obrasPorDireccion.get(direccion).push(obra);
|
||||||
|
});
|
||||||
|
|
||||||
|
processingStats.value.total = allObras.length;
|
||||||
|
|
||||||
|
// Procesar grupos de obras por dirección en lotes
|
||||||
|
const direcciones = Array.from(obrasPorDireccion.keys());
|
||||||
|
const batchSize = 5;
|
||||||
|
|
||||||
|
for (let i = 0; i < direcciones.length; i += batchSize) {
|
||||||
|
const batch = direcciones.slice(i, i + batchSize);
|
||||||
|
const batchPromises = batch.map(direccion =>
|
||||||
|
createMarkerForDireccion(direccion, obrasPorDireccion.get(direccion))
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.allSettled(batchPromises);
|
||||||
|
|
||||||
|
if (i + batchSize < direcciones.length) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateMarkers();
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMarkerForDireccion = async (direccion, obrasEnDireccion) => {
|
||||||
|
if (!direccion || !geocoder.value) {
|
||||||
|
obrasEnDireccion.forEach(() => processingStats.value.failed++);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construir direcciones alternativas para geocodificación
|
||||||
|
const addresses = [
|
||||||
|
`${direccion}, Comalcalco, Tabasco, México`,
|
||||||
|
`${direccion}, Tabasco, México`,
|
||||||
|
`${direccion}, México`,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Verificar cache primero
|
||||||
|
const cacheKey = direccion;
|
||||||
|
if (geocodingCache.has(cacheKey)) {
|
||||||
|
const cachedData = geocodingCache.get(cacheKey);
|
||||||
|
processingStats.value.duplicates += obrasEnDireccion.length - 1;
|
||||||
|
return createMarkerFromPosition(obrasEnDireccion, cachedData.position, cachedData.formatted_address);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intentar geocodificación con direcciones alternativas
|
||||||
|
for (const address of addresses) {
|
||||||
|
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(obrasEnDireccion, result.position, result.formatted_address);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Error geocodificando ${address}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si todas las direcciones fallan, crear marcador fallback
|
||||||
|
processingStats.value.failed += obrasEnDireccion.length;
|
||||||
|
|
||||||
|
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(obrasEnDireccion, fallbackPosition, "Ubicación aproximada - Comalcalco, Tabasco");
|
||||||
|
};
|
||||||
|
|
||||||
|
const geocodeAddress = (address) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
geocoder.value.geocode({ address }, (results, status) => {
|
||||||
|
if (status === "OK" && results[0]) {
|
||||||
|
const position = results[0].geometry.location;
|
||||||
|
const lat = position.lat();
|
||||||
|
const lng = position.lng();
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
position: { lat, lng },
|
||||||
|
formatted_address: results[0].formatted_address,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
status,
|
||||||
|
address,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMarkerFromPosition = (obrasEnPosicion, position, formatted_address) => {
|
||||||
|
const lat = position.lat;
|
||||||
|
const lng = position.lng;
|
||||||
|
|
||||||
|
// Verificar límites expandidos
|
||||||
|
if (
|
||||||
|
lat < TABASCO_BOUNDS.south ||
|
||||||
|
lat > TABASCO_BOUNDS.north ||
|
||||||
|
lng < TABASCO_BOUNDS.west ||
|
||||||
|
lng > TABASCO_BOUNDS.east
|
||||||
|
) {
|
||||||
|
processingStats.value.outOfBounds += obrasEnPosicion.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
? `${obraPrincipal.num_proyecto} - ${obraPrincipal.direccion_obra}`
|
||||||
|
: `${obrasEnPosicion.length} obras en ${obraPrincipal.direccion_obra}`,
|
||||||
|
icon: createCustomMarker(obraPrincipal.estado),
|
||||||
|
visible: filters[obraPrincipal.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];
|
||||||
|
const width = 32;
|
||||||
|
const height = 48;
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
|
||||||
|
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<!-- Sombra para el marcador -->
|
||||||
|
<filter id="dropshadow-${estado}" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-color="rgba(0,0,0,0.3)" flood-opacity="0.5"/>
|
||||||
|
</filter>
|
||||||
|
|
||||||
|
<!-- Gradiente para dar volumen -->
|
||||||
|
<radialGradient id="gradient-${estado}" cx="30%" cy="20%" r="70%">
|
||||||
|
<stop offset="0%" style="stop-color:white;stop-opacity:0.3" />
|
||||||
|
<stop offset="100%" style="stop-color:${color};stop-opacity:1" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Forma principal del marcador (gota/pin) -->
|
||||||
|
<path d="M ${width / 2} ${height - 4}
|
||||||
|
C ${width / 2} ${height - 4},
|
||||||
|
${width * 0.125} ${height * 0.58},
|
||||||
|
${width * 0.125} ${height * 0.375}
|
||||||
|
C ${width * 0.125} ${height * 0.167},
|
||||||
|
${width * 0.292} 2,
|
||||||
|
${width / 2} 2
|
||||||
|
C ${width * 0.708} 2,
|
||||||
|
${width * 0.875} ${height * 0.167},
|
||||||
|
${width * 0.875} ${height * 0.375}
|
||||||
|
C ${width * 0.875} ${height * 0.58},
|
||||||
|
${width / 2} ${height - 4},
|
||||||
|
${width / 2} ${height - 4} Z"
|
||||||
|
fill="url(#gradient-${estado})"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="1.5"
|
||||||
|
filter="url(#dropshadow-${estado})"/>
|
||||||
|
|
||||||
|
<!-- Borde interior más oscuro -->
|
||||||
|
<path d="M ${width / 2} ${height - 4}
|
||||||
|
C ${width / 2} ${height - 4},
|
||||||
|
${width * 0.125} ${height * 0.58},
|
||||||
|
${width * 0.125} ${height * 0.375}
|
||||||
|
C ${width * 0.125} ${height * 0.167},
|
||||||
|
${width * 0.292} 2,
|
||||||
|
${width / 2} 2
|
||||||
|
C ${width * 0.708} 2,
|
||||||
|
${width * 0.875} ${height * 0.167},
|
||||||
|
${width * 0.875} ${height * 0.375}
|
||||||
|
C ${width * 0.875} ${height * 0.58},
|
||||||
|
${width / 2} ${height - 4},
|
||||||
|
${width / 2} ${height - 4} Z"
|
||||||
|
fill="none"
|
||||||
|
stroke="${color}"
|
||||||
|
stroke-width="1"
|
||||||
|
opacity="0.8"/>
|
||||||
|
|
||||||
|
<!-- Círculo interior blanco -->
|
||||||
|
<circle cx="${width / 2}"
|
||||||
|
cy="${height * 0.375}"
|
||||||
|
r="${width * 0.25}"
|
||||||
|
fill="white"
|
||||||
|
stroke="${color}"
|
||||||
|
stroke-width="1"/>
|
||||||
|
</svg>
|
||||||
|
`)}`,
|
||||||
|
scaledSize: new google.maps.Size(width, height),
|
||||||
|
anchor: new google.maps.Point(width / 2, height - 4),
|
||||||
|
origin: new google.maps.Point(0, 0),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
<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>
|
||||||
@ -1314,7 +1314,11 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-12 -mb-10">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">
|
||||||
|
Obras - Últimos 30 Días
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
<div class="mt-12 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div class="mt-12 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<div class="bg-white shadow rounded-lg p-6">
|
<div class="bg-white shadow rounded-lg p-6">
|
||||||
<h3 class="font-semibold text-lg">Sin avances</h3>
|
<h3 class="font-semibold text-lg">Sin avances</h3>
|
||||||
@ -1325,15 +1329,6 @@ onMounted(() => {
|
|||||||
}}</span>
|
}}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white shadow rounded-lg p-6">
|
|
||||||
<h3 class="font-semibold text-lg">Finalizadas</h3>
|
|
||||||
<p class="mt-2 text-gray-600">
|
|
||||||
Total:
|
|
||||||
<span class="font-bold text-red-600">{{
|
|
||||||
counters.finalizadas.count
|
|
||||||
}}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="bg-white shadow rounded-lg p-6">
|
<div class="bg-white shadow rounded-lg p-6">
|
||||||
<h3 class="font-semibold text-lg">Obras abiertas</h3>
|
<h3 class="font-semibold text-lg">Obras abiertas</h3>
|
||||||
<p class="mt-2 text-gray-600">
|
<p class="mt-2 text-gray-600">
|
||||||
@ -1343,6 +1338,15 @@ onMounted(() => {
|
|||||||
}}</span>
|
}}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="bg-white shadow rounded-lg p-6">
|
||||||
|
<h3 class="font-semibold text-lg">Finalizadas</h3>
|
||||||
|
<p class="mt-2 text-gray-600">
|
||||||
|
Total:
|
||||||
|
<span class="font-bold text-red-600">{{
|
||||||
|
counters.finalizadas.count
|
||||||
|
}}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<ObrasMap :counters="counters" />
|
<ObrasMap :counters="counters" />
|
||||||
@ -1353,10 +1357,10 @@ onMounted(() => {
|
|||||||
class="px-6 py-4 bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200"
|
class="px-6 py-4 bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200"
|
||||||
>
|
>
|
||||||
<h3 class="text-lg font-medium text-gray-900">
|
<h3 class="text-lg font-medium text-gray-900">
|
||||||
Supervisores y sus Últimas Acciones - Últimos 30 Días
|
Residentes y sus Últimas Acciones - Últimos 30 Días
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-sm text-gray-600 mt-1">
|
<p class="text-sm text-gray-600 mt-1">
|
||||||
Registro de actividad de supervisores en proyectos
|
Registro de actividad de residentes en proyectos
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1367,7 +1371,7 @@ onMounted(() => {
|
|||||||
<th
|
<th
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
Supervisor
|
Residente
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
@ -1469,7 +1473,7 @@ onMounted(() => {
|
|||||||
<tr v-if="!users.length">
|
<tr v-if="!users.length">
|
||||||
<td colspan="5" class="px-6 py-8 text-center">
|
<td colspan="5" class="px-6 py-8 text-center">
|
||||||
<div class="text-gray-500">
|
<div class="text-gray-500">
|
||||||
No hay supervisores registrados
|
No hay residentes registrados
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user