695 lines
21 KiB
Vue
695 lines
21 KiB
Vue
<script setup>
|
|
import { ref, reactive, onMounted, onUnmounted, 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);
|
|
const isMounted = ref(false);
|
|
|
|
const filters = reactive({
|
|
sin_avances: true,
|
|
abiertas: true,
|
|
finalizadas: true,
|
|
});
|
|
|
|
const searchQuery = ref("");
|
|
const searchResults = ref([]);
|
|
const showSearchResults = ref(false);
|
|
const searchLoading = ref(false);
|
|
|
|
const geocodingCache = new Map();
|
|
const failedGeocodings = ref([]);
|
|
|
|
const markerColors = {
|
|
sin_avances: "#EF4444",
|
|
abiertas: "#F59E0B",
|
|
finalizadas: "#10B981",
|
|
};
|
|
const COMALCALCO_CENTER = { lat: 18.26, lng: -93.25 };
|
|
|
|
const COMALCALCO_BOUNDS = {
|
|
south: 18.1,
|
|
west: -93.4,
|
|
north: 18.4,
|
|
east: -93.0,
|
|
};
|
|
|
|
const visibleMarkersCount = computed(
|
|
() => markers.value.filter((m) => m.visible).length
|
|
);
|
|
|
|
onMounted(() => {
|
|
isMounted.value = true;
|
|
initMap();
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
isMounted.value = false;
|
|
});
|
|
|
|
watch(
|
|
() => props.counters,
|
|
() => {
|
|
if (map.value && geocoder.value) {
|
|
loadMarkers();
|
|
}
|
|
},
|
|
{ deep: true }
|
|
);
|
|
|
|
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",
|
|
// Usar solo librerías básicas por compatibilidad
|
|
libraries: ["places", "geometry"],
|
|
});
|
|
window.google = await loader.load();
|
|
|
|
if (!isMounted.value) return;
|
|
|
|
map.value = new google.maps.Map(mapContainer.value, {
|
|
center: COMALCALCO_CENTER,
|
|
zoom: 11,
|
|
// Mantener mapId pero para funcionalidad futura
|
|
mapId: "OBRAS_MAP_COMALCALCO",
|
|
mapTypeControl: true,
|
|
streetViewControl: true,
|
|
fullscreenControl: true,
|
|
zoomControl: true,
|
|
// Estilos mejorados
|
|
styles: [
|
|
{
|
|
featureType: "poi",
|
|
elementType: "labels.icon",
|
|
stylers: [{ visibility: "off" }],
|
|
},
|
|
{
|
|
featureType: "transit",
|
|
elementType: "labels.icon",
|
|
stylers: [{ visibility: "off" }],
|
|
},
|
|
],
|
|
});
|
|
geocoder.value = new google.maps.Geocoder();
|
|
await loadMarkers();
|
|
} catch (err) {
|
|
if (!isMounted.value) return;
|
|
console.error("Error loading map:", err);
|
|
error.value = err.message || "Error al cargar Google Maps";
|
|
} finally {
|
|
if (isMounted.value) loading.value = false;
|
|
}
|
|
};
|
|
|
|
const loadMarkers = async () => {
|
|
if (!map.value || !geocoder.value || !isMounted.value) return;
|
|
loading.value = true;
|
|
clearMarkers();
|
|
failedGeocodings.value = [];
|
|
|
|
const allObras = getAllObras();
|
|
console.log("Total obras a procesar:", allObras.length);
|
|
|
|
if (allObras.length === 0) {
|
|
if (isMounted.value) loading.value = false;
|
|
return;
|
|
}
|
|
|
|
const obrasPorGrupo = groupObrasByAddressAndStatus(allObras);
|
|
const uniqueAddresses = [
|
|
...new Set(Array.from(obrasPorGrupo.values()).map((g) => g.direccion)),
|
|
].filter(Boolean);
|
|
|
|
console.log("Direcciones únicas a geocodificar:", uniqueAddresses.length);
|
|
|
|
await geocodeUniqueAddresses(uniqueAddresses);
|
|
|
|
if (!isMounted.value) return;
|
|
|
|
createMarkersFromGroups(obrasPorGrupo);
|
|
loading.value = false;
|
|
updateMarkers();
|
|
|
|
console.log("Marcadores creados exitosamente:", markers.value.length);
|
|
};
|
|
|
|
const normalizeAddressForGeocoding = (address) => {
|
|
if (!address) return "";
|
|
return address
|
|
.replace(/^RA\.\s*/, "RANCHERÍA ")
|
|
.replace(/(\d+)\s*DA\s*SECCION/i, "$1 SECCION")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
};
|
|
|
|
const geocodeAndCacheAddress = (address) => {
|
|
if (!address || geocodingCache.has(address)) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
const normalizedAddress = normalizeAddressForGeocoding(address);
|
|
|
|
const request = {
|
|
address: normalizedAddress,
|
|
componentRestrictions: {
|
|
country: "MX",
|
|
administrativeArea: "Tabasco",
|
|
},
|
|
bounds: new google.maps.LatLngBounds(
|
|
{ lat: COMALCALCO_BOUNDS.south, lng: COMALCALCO_BOUNDS.west },
|
|
{ lat: COMALCALCO_BOUNDS.north, lng: COMALCALCO_BOUNDS.east }
|
|
),
|
|
};
|
|
|
|
geocoder.value.geocode(request, (results, status) => {
|
|
if (status === "OK" && results?.length) {
|
|
// Con la búsqueda sesgada, el primer resultado es ahora mucho más confiable
|
|
const bestResult = results[0];
|
|
geocodingCache.set(address, {
|
|
success: true,
|
|
position: {
|
|
lat: bestResult.geometry.location.lat(),
|
|
lng: bestResult.geometry.location.lng(),
|
|
},
|
|
formatted_address: bestResult.formatted_address,
|
|
});
|
|
} else {
|
|
geocodingCache.set(address, { success: false, status });
|
|
}
|
|
resolve();
|
|
});
|
|
});
|
|
};
|
|
|
|
const groupObrasByAddressAndStatus = (obras) => {
|
|
const grouped = new Map();
|
|
obras.forEach((obra) => {
|
|
const key = `${obra.direccion_obra}|${obra.estado}`;
|
|
if (!grouped.has(key)) {
|
|
grouped.set(key, {
|
|
direccion: obra.direccion_obra,
|
|
estado: obra.estado,
|
|
obras: [],
|
|
});
|
|
}
|
|
grouped.get(key).obras.push(obra);
|
|
});
|
|
return grouped;
|
|
};
|
|
|
|
const geocodeUniqueAddresses = async (addresses) => {
|
|
const geocodingPromises = addresses.map((addr) =>
|
|
geocodeAndCacheAddress(addr)
|
|
);
|
|
await Promise.all(geocodingPromises);
|
|
};
|
|
|
|
const createMarkersFromGroups = (obrasPorGrupo) => {
|
|
const statusOffsets = {
|
|
sin_avances: { lat: 0.00008, lng: -0.00005 },
|
|
abiertas: { lat: 0, lng: 0.0001 },
|
|
finalizadas: { lat: -0.00004, lng: -0.00005 },
|
|
};
|
|
|
|
for (const grupo of obrasPorGrupo.values()) {
|
|
const geocodedResult = geocodingCache.get(grupo.direccion);
|
|
if (geocodedResult?.success) {
|
|
const offset = statusOffsets[grupo.estado] || { lat: 0, lng: 0 };
|
|
const position = {
|
|
lat: geocodedResult.position.lat + offset.lat,
|
|
lng: geocodedResult.position.lng + offset.lng,
|
|
};
|
|
createMarker(
|
|
position,
|
|
grupo.estado,
|
|
grupo.obras,
|
|
geocodedResult.formatted_address
|
|
);
|
|
} else {
|
|
failedGeocodings.value.push({
|
|
direccion: grupo.direccion,
|
|
motivo: geocodedResult?.status || "NOT_FOUND",
|
|
obras: grupo.obras,
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const createMarker = (position, estado, obras, formatted_address) => {
|
|
try {
|
|
const svgIcon = createSVGIcon(estado, obras.length);
|
|
|
|
const marker = new google.maps.Marker({
|
|
position: new google.maps.LatLng(position.lat, position.lng),
|
|
map: map.value,
|
|
title: `${obras.length} obra(s) en ${obras[0].direccion_obra}`,
|
|
icon: {
|
|
url: svgIcon,
|
|
scaledSize: new google.maps.Size(32, 40),
|
|
anchor: new google.maps.Point(16, 40),
|
|
},
|
|
animation: google.maps.Animation.DROP,
|
|
optimized: false,
|
|
});
|
|
|
|
const infoWindow = new google.maps.InfoWindow({
|
|
content: createMultipleInfoWindowContent(obras, formatted_address),
|
|
maxWidth: 400,
|
|
});
|
|
|
|
marker.addListener("click", () => {
|
|
markers.value.forEach((m) => m.infoWindow?.close());
|
|
infoWindow.open(map.value, marker);
|
|
});
|
|
|
|
markers.value.push({
|
|
marker,
|
|
infoWindow,
|
|
estado,
|
|
obras,
|
|
visible: filters[estado],
|
|
position,
|
|
});
|
|
} catch (error) {
|
|
console.error("Error creando marcador:", error);
|
|
}
|
|
};
|
|
|
|
// Crear icono SVG
|
|
const createSVGIcon = (estado, count) => {
|
|
const color = markerColors[estado];
|
|
const svg = `
|
|
<svg width="32" height="40" viewBox="0 0 32 40" xmlns="http://www.w3.org/2000/svg">
|
|
<defs>
|
|
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
|
|
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-opacity="0.3"/>
|
|
</filter>
|
|
</defs>
|
|
<path d="M16 0C7.163 0 0 7.163 0 16c0 12 16 24 16 24s16-12 16-24c0-8.837-7.163-16-16-16z"
|
|
fill="${color}"
|
|
stroke="white"
|
|
stroke-width="2"
|
|
filter="url(#shadow)"/>
|
|
<circle cx="16" cy="16" r="10" fill="white"/>
|
|
<circle cx="16" cy="16" r="7" fill="${color}"/>
|
|
<text x="16" y="21" text-anchor="middle" fill="white" font-size="10" font-weight="bold" font-family="Arial">${count}</text>
|
|
</svg>
|
|
`;
|
|
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`;
|
|
};
|
|
|
|
const updateMarkers = () => {
|
|
markers.value.forEach((m) => {
|
|
const shouldShow = filters[m.estado];
|
|
m.marker.setVisible(shouldShow);
|
|
m.visible = shouldShow;
|
|
});
|
|
console.log("Marcadores actualizados. Visibles:", visibleMarkersCount.value);
|
|
};
|
|
|
|
const clearMarkers = () => {
|
|
markers.value.forEach(({ marker, infoWindow }) => {
|
|
try {
|
|
infoWindow?.close();
|
|
marker.setMap(null);
|
|
} catch (error) {
|
|
console.error("Error limpiando marcador:", error);
|
|
}
|
|
});
|
|
markers.value = [];
|
|
// No limpiar cache para mejorar rendimiento
|
|
};
|
|
|
|
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);
|
|
|
|
const listener = google.maps.event.addListener(map.value, "idle", () => {
|
|
if (map.value.getZoom() > 15) map.value.setZoom(15);
|
|
google.maps.event.removeListener(listener);
|
|
});
|
|
};
|
|
|
|
const getAllObras = () => {
|
|
try {
|
|
return [
|
|
...props.counters.sin_avances.detalles.map((o) => ({
|
|
...o,
|
|
estado: "sin_avances",
|
|
})),
|
|
...props.counters.abiertas.detalles.map((o) => ({
|
|
...o,
|
|
estado: "abiertas",
|
|
})),
|
|
...props.counters.finalizadas.detalles.map((o) => ({
|
|
...o,
|
|
estado: "finalizadas",
|
|
})),
|
|
];
|
|
} catch (error) {
|
|
console.error("Error obteniendo obras:", error);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
const searchAddresses = () => {
|
|
if (!searchQuery.value.trim()) {
|
|
searchResults.value = [];
|
|
showSearchResults.value = false;
|
|
return;
|
|
}
|
|
const query = searchQuery.value.toLowerCase();
|
|
const allObras = getAllObras();
|
|
searchResults.value = allObras
|
|
.filter(
|
|
(obra) =>
|
|
obra.direccion_obra?.toLowerCase().includes(query) ||
|
|
obra.num_proyecto?.toLowerCase().includes(query)
|
|
)
|
|
.slice(0, 10);
|
|
showSearchResults.value = true;
|
|
};
|
|
|
|
const goToObra = (obra) => {
|
|
searchQuery.value = "";
|
|
searchResults.value = [];
|
|
showSearchResults.value = false;
|
|
|
|
const markerData = markers.value.find((m) =>
|
|
m.obras.some((o) => o.num_proyecto === obra.num_proyecto)
|
|
);
|
|
|
|
if (markerData) {
|
|
map.value.setCenter(markerData.position);
|
|
map.value.setZoom(16);
|
|
markers.value.forEach((m) => m.infoWindow?.close());
|
|
markerData.infoWindow.open(map.value, markerData.marker);
|
|
}
|
|
};
|
|
|
|
const clearSearch = () => {
|
|
searchQuery.value = "";
|
|
searchResults.value = [];
|
|
showSearchResults.value = false;
|
|
};
|
|
|
|
const createMultipleInfoWindowContent = (obras, formatted_address) => {
|
|
const direccion = obras[0].direccion_obra;
|
|
let obrasHtml = obras
|
|
.map((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="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" style="width: ${
|
|
Number(obra.cumplimiento_total) || 0
|
|
}%; background-color: ${
|
|
markerColors[obra.estado]
|
|
};"></div></div><span class="text-xs font-medium text-gray-700 min-w-[35px]">${cumplimiento}</span></div></div>`;
|
|
})
|
|
.join("");
|
|
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
|
|
} Obra(s) 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>`;
|
|
};
|
|
</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>
|
|
|
|
<div class="flex items-center space-x-4">
|
|
<div class="relative">
|
|
<div class="relative">
|
|
<input
|
|
v-model="searchQuery"
|
|
@input="searchAddresses"
|
|
@focus="showSearchResults = true"
|
|
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 pointer-events-none"
|
|
>
|
|
<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 pointer-events-auto"
|
|
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
|
|
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"
|
|
@click="goToObra(obra)"
|
|
class="px-3 py-2 hover:bg-gray-50 cursor-pointer"
|
|
>
|
|
<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>
|
|
<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>
|
|
<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 ref="mapContainer" class="w-full h-[600px] relative">
|
|
<div
|
|
v-if="loading"
|
|
class="absolute inset-0 bg-gray-100/80 flex items-center justify-center z-10 backdrop-blur-sm"
|
|
>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-if="error"
|
|
class="absolute inset-0 bg-red-50 flex items-center justify-center z-10"
|
|
>
|
|
<div class="text-center">
|
|
<h3 class="text-sm font-medium text-red-800">
|
|
Error al cargar el mapa
|
|
</h3>
|
|
<p class="mt-1 text-sm text-red-600">{{ error }}</p>
|
|
<button
|
|
@click="initMap"
|
|
class="mt-4 bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700"
|
|
>
|
|
Reintentar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="!loading && failedGeocodings.length > 0"
|
|
class="px-6 py-4 bg-yellow-50 border-t border-yellow-200"
|
|
>
|
|
<h4 class="text-sm font-semibold text-yellow-800">
|
|
{{ failedGeocodings.flatMap((f) => f.obras).length }} obra(s) no
|
|
pudieron ser ubicadas
|
|
</h4>
|
|
<p class="text-xs text-yellow-700 mb-2">
|
|
Las siguientes direcciones no fueron encontradas o la coincidencia era
|
|
de baja calidad. Considera revisarlas en la base de datos.
|
|
</p>
|
|
<ul class="text-xs text-gray-700 list-disc pl-5 max-h-32 overflow-y-auto">
|
|
<li v-for="item in failedGeocodings" :key="item.direccion">
|
|
<strong>{{ item.direccion }}</strong> ({{ item.obras.length }} obra/s)
|
|
- Motivo:
|
|
<span class="font-mono text-red-600">{{ item.motivo }}</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</template>
|