Añadir mapa de cercanía en Sin Cita (asignados.html)

This commit is contained in:
2026-08-23 21:56:03 +00:00
parent 0dc7af9b50
commit 6704946869

View File

@@ -6,6 +6,8 @@
<title>Pendientes de Cita - IntegraRepara</title> <title>Pendientes de Cita - IntegraRepara</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script> <script src="https://unpkg.com/lucide@latest"></script>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style> <style>
/* VARIABLES CORPORATIVAS DINÁMICAS */ /* VARIABLES CORPORATIVAS DINÁMICAS */
:root { :root {
@@ -46,9 +48,14 @@
<p class="text-[10px] font-black text-primary-dynamic uppercase tracking-widest mb-0.5">Por Agendar</p> <p class="text-[10px] font-black text-primary-dynamic uppercase tracking-widest mb-0.5">Por Agendar</p>
<h1 class="text-2xl font-black tracking-tight text-slate-800 leading-none">Sin Cita</h1> <h1 class="text-2xl font-black tracking-tight text-slate-800 leading-none">Sin Cita</h1>
</div> </div>
<div class="flex items-center gap-2">
<button onclick="openProximityMap()" class="w-10 h-10 bg-primary-dynamic/10 rounded-full flex items-center justify-center text-primary-dynamic border border-primary-dynamic/20 active:bg-primary-dynamic/20 transition-colors">
<i data-lucide="map-pinned" class="w-5 h-5"></i>
</button>
<a href="menu.html" class="w-10 h-10 bg-slate-50 rounded-full flex items-center justify-center text-slate-600 border border-slate-200 active:bg-slate-100 transition-colors"> <a href="menu.html" class="w-10 h-10 bg-slate-50 rounded-full flex items-center justify-center text-slate-600 border border-slate-200 active:bg-slate-100 transition-colors">
<i data-lucide="arrow-left" class="w-5 h-5"></i> <i data-lucide="arrow-left" class="w-5 h-5"></i>
</a> </a>
</div>
</header> </header>
<main class="flex-1 overflow-y-auto no-scrollbar p-5 relative z-10" id="mainArea"> <main class="flex-1 overflow-y-auto no-scrollbar p-5 relative z-10" id="mainArea">
<div id="loader" class="text-center py-10 opacity-50"> <div id="loader" class="text-center py-10 opacity-50">
@@ -212,6 +219,27 @@
<i data-lucide="check-circle" class="w-4 h-4 text-emerald-400"></i> <i data-lucide="check-circle" class="w-4 h-4 text-emerald-400"></i>
<span id="toastMsg">Guardado</span> <span id="toastMsg">Guardado</span>
</div> </div>
<div id="proximityModal" class="fixed inset-0 bg-white z-[150] hidden flex-col">
<div class="bg-white px-5 pt-8 pb-4 shadow-sm shrink-0 border-b border-slate-100 flex justify-between items-center">
<div>
<p class="text-[10px] font-black text-primary-dynamic uppercase tracking-widest mb-0.5">Localización</p>
<h1 class="text-xl font-black tracking-tight text-slate-800 leading-none">Mapa de Cercanía</h1>
</div>
<button onclick="closeProximityMap()" class="w-10 h-10 bg-slate-50 rounded-full flex items-center justify-center text-slate-600 border border-slate-200 active:bg-slate-100 transition-colors">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div id="proximityMapContainer" class="shrink-0 bg-slate-100" style="height:38vh; width:100%;"></div>
<div id="proximityStatus" class="px-5 py-6 text-center shrink-0">
<i data-lucide="loader-2" class="w-6 h-6 animate-spin mx-auto text-primary-dynamic mb-2"></i>
<p class="text-xs font-bold uppercase tracking-widest text-slate-400" id="proximityStatusMsg">Obteniendo tu ubicación...</p>
</div>
<div id="proximityListWrapper" class="flex-1 overflow-y-auto no-scrollbar p-5 pt-2 hidden">
<div id="proximityList" class="space-y-3"></div>
</div>
</div>
<script> <script>
const API_URL = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' const API_URL = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
? 'http://localhost:3000' ? 'http://localhost:3000'
@@ -889,6 +917,225 @@ function renderServices(servicesToRender = localServices) {
t.classList.remove('translate-y-0'); t.classList.remove('translate-y-0');
}, 2500); }, 2500);
} }
// ==========================================
// 🗺️ MAPA DE CERCANÍA (proximidad respecto a mi posición)
// ==========================================
let proximityAbort = false;
let proximityMap = null;
const proximityGeoCache = {}; // { "direccion normalizada": {lat, lng} | null }
function buildFullAddress(raw) {
const addr = raw["Dirección"] || raw["DOMICILIO"] || "";
const cp = raw["Código Postal"] || raw["C.P."] || "";
const pop = raw["Población"] || raw["POBLACION-PROVINCIA"] || "";
return `${addr}, ${cp} ${pop}`.trim().replace(/^,\s*/, '').replace(/,\s*$/, '');
}
function extractPhone(raw) {
const rawPhone = raw["Teléfono"] || raw["TELEFONOS"] || raw["TELEFONO"] || "";
const m = String(rawPhone).match(/[6789]\d{8}/);
return m ? m[0] : "";
}
function haversineKm(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
async function geocodeAddress(addressStr) {
const key = addressStr.toLowerCase().trim();
if (!key) return null;
if (key in proximityGeoCache) return proximityGeoCache[key];
try {
let geoRes = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(addressStr + ', España')}`);
let geoData = await geoRes.json();
if (!geoData || geoData.length === 0) {
const parts = addressStr.split(',');
const fallback = parts.length > 1 ? parts[parts.length - 1].trim() : addressStr;
geoRes = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(fallback + ', España')}`);
geoData = await geoRes.json();
}
if (geoData && geoData.length > 0) {
const result = { lat: parseFloat(geoData[0].lat), lng: parseFloat(geoData[0].lon) };
proximityGeoCache[key] = result;
return result;
}
} catch (e) { /* seguimos sin coordenadas para esta dirección */ }
proximityGeoCache[key] = null;
return null;
}
function collectProximityCandidates() {
const candidates = [];
localServices.forEach(s => {
const raw = s.raw_data || {};
const address = buildFullAddress(raw);
if (!address || address === ",") return;
candidates.push({
id: s.id, type: 'sin_cita', address,
name: raw["Nombre Cliente"] || raw["CLIENTE"] || "Asegurado",
company: raw["Compañía"] || raw["Procedencia"] || "Particular",
phone: extractPhone(raw), isUrgent: !!s.is_urgent
});
});
pendingRequests.forEach(req => {
const raw = req.raw_data || {};
const address = buildFullAddress(raw);
if (!address || address === ",") return;
candidates.push({
id: req.id, type: 'solicitada', address,
name: raw["Nombre Cliente"] || raw["CLIENTE"] || "Cliente",
company: raw["Compañía"] || raw["Procedencia"] || "Particular",
phone: extractPhone(raw), isUrgent: false
});
});
return candidates;
}
function openProximityMap() {
proximityAbort = false;
const modal = document.getElementById('proximityModal');
modal.classList.remove('hidden');
document.getElementById('proximityListWrapper').classList.add('hidden');
document.getElementById('proximityList').innerHTML = '';
const statusBox = document.getElementById('proximityStatus');
const statusMsg = document.getElementById('proximityStatusMsg');
statusBox.classList.remove('hidden');
statusBox.innerHTML = `<i data-lucide="loader-2" class="w-6 h-6 animate-spin mx-auto text-primary-dynamic mb-2"></i><p class="text-xs font-bold uppercase tracking-widest text-slate-400" id="proximityStatusMsg">Obteniendo tu ubicación...</p>`;
lucide.createIcons();
if (proximityMap) { proximityMap.remove(); proximityMap = null; }
const candidates = collectProximityCandidates();
if (candidates.length === 0) {
document.getElementById('proximityStatus').innerHTML = `
<i data-lucide="map-pin-off" class="w-8 h-8 mx-auto text-slate-300 mb-2"></i>
<p class="text-xs font-bold uppercase tracking-widest text-slate-400">No hay avisos con dirección para ubicar.</p>
`;
lucide.createIcons();
return;
}
if (!navigator.geolocation) {
showProximityError("Este dispositivo no permite obtener la ubicación.");
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => runProximitySearch(pos.coords.latitude, pos.coords.longitude, candidates),
(err) => {
let msg = "No se pudo obtener tu ubicación.";
if (err.code === err.PERMISSION_DENIED) msg = "Activa el permiso de ubicación para ver el mapa de cercanía.";
else if (err.code === err.TIMEOUT) msg = "Se ha agotado el tiempo esperando tu ubicación.";
showProximityError(msg);
},
{ enableHighAccuracy: true, timeout: 12000, maximumAge: 60000 }
);
}
function showProximityError(msg) {
document.getElementById('proximityStatus').innerHTML = `
<i data-lucide="alert-triangle" class="w-8 h-8 mx-auto text-amber-400 mb-2"></i>
<p class="text-xs font-bold uppercase tracking-widest text-slate-400 mb-3">${msg}</p>
<button onclick="openProximityMap()" class="bg-primary-dynamic text-white text-[10px] font-black uppercase tracking-widest px-4 py-2 rounded-xl">Reintentar</button>
`;
lucide.createIcons();
}
async function runProximitySearch(myLat, myLng, candidates) {
const statusMsg = document.getElementById('proximityStatusMsg');
const results = [];
for (let i = 0; i < candidates.length; i++) {
if (proximityAbort) return;
if (statusMsg) statusMsg.innerText = `Localizando direcciones... (${i + 1}/${candidates.length})`;
const c = candidates[i];
const key = c.address.toLowerCase().trim();
const alreadyCached = key in proximityGeoCache;
const coords = await geocodeAddress(c.address);
results.push({ ...c, lat: coords ? coords.lat : null, lng: coords ? coords.lng : null,
distanceKm: coords ? haversineKm(myLat, myLng, coords.lat, coords.lng) : null });
if (!alreadyCached) await sleep(1100); // respeto al límite de uso de Nominatim (máx. 1 req/seg)
}
if (proximityAbort) return;
results.sort((a, b) => {
if (a.distanceKm === null) return 1;
if (b.distanceKm === null) return -1;
return a.distanceKm - b.distanceKm;
});
document.getElementById('proximityStatus').classList.add('hidden');
renderProximityMap(myLat, myLng, results);
renderProximityList(results);
}
function renderProximityMap(myLat, myLng, results) {
const mapDiv = document.getElementById('proximityMapContainer');
if (typeof L === 'undefined') return;
proximityMap = L.map(mapDiv, { zoomControl: true, attributionControl: false }).setView([myLat, myLng], 12);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(proximityMap);
const meIcon = L.divIcon({
className: '', iconSize: [30, 30], iconAnchor: [15, 15],
html: '<div style="background:#2563eb;width:30px;height:30px;border-radius:50%;display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 6px rgba(37,99,235,0.25);border:2px solid white;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle></svg></div>'
});
const bounds = [[myLat, myLng]];
L.marker([myLat, myLng], { icon: meIcon }).addTo(proximityMap).bindPopup("Tu posición");
results.forEach((r, idx) => {
if (r.lat === null) return;
const color = r.type === 'solicitada' ? '#10b981' : (r.isUrgent ? '#ef4444' : '#f59e0b');
const pinIcon = L.divIcon({
className: '', iconSize: [28, 28], iconAnchor: [14, 28],
html: `<div style="background:${color};width:28px;height:28px;border-radius:50% 50% 50% 0;transform:rotate(-45deg);box-shadow:0 2px 6px rgba(0,0,0,0.3);border:2px solid white;display:flex;align-items:center;justify-content:center;"><span style="transform:rotate(45deg);color:white;font-size:11px;font-weight:900;">${idx + 1}</span></div>`
});
L.marker([r.lat, r.lng], { icon: pinIcon }).addTo(proximityMap)
.bindPopup(`<b>${idx + 1}. ${r.name}</b><br>${r.distanceKm.toFixed(1)} km`);
bounds.push([r.lat, r.lng]);
});
if (bounds.length > 1) proximityMap.fitBounds(bounds, { padding: [40, 40] });
}
function renderProximityList(results) {
const wrapper = document.getElementById('proximityListWrapper');
const list = document.getElementById('proximityList');
wrapper.classList.remove('hidden');
list.innerHTML = results.map((r, idx) => {
const hasCoords = r.lat !== null;
const badgeColor = r.type === 'solicitada' ? 'bg-emerald-500' : (r.isUrgent ? 'bg-red-500' : 'bg-primary-dynamic');
const distanceLabel = hasCoords ? `${r.distanceKm.toFixed(1)} km` : 'Sin ubicar';
const dirUrl = hasCoords ? `https://www.google.com/maps/dir/?api=1&destination=${r.lat},${r.lng}` : null;
const openDetail = r.type === 'solicitada' ? `openApproveModal(${r.id})` : `openActionModal(${r.id})`;
return `
<div class="bg-white border border-slate-200 rounded-2xl p-4 shadow-sm flex items-start gap-3">
<div class="w-7 h-7 rounded-full ${badgeColor} text-white text-[11px] font-black flex items-center justify-center shrink-0 mt-0.5">${hasCoords ? idx + 1 : '?'}</div>
<div class="min-w-0 flex-1 cursor-pointer" onclick="closeProximityMap(); ${openDetail};">
<p class="text-[9px] font-black text-slate-400 uppercase tracking-widest truncate">${r.company} · ${distanceLabel}</p>
<h4 class="font-black text-slate-800 text-sm uppercase leading-tight truncate">${r.name}</h4>
<p class="text-[10px] font-bold text-slate-500 mt-0.5 truncate">${r.address}</p>
</div>
<div class="flex flex-col gap-1.5 shrink-0">
${dirUrl ? `<a href="${dirUrl}" target="_blank" onclick="event.stopPropagation()" class="w-8 h-8 bg-blue-50 border border-blue-100 text-blue-600 rounded-lg flex items-center justify-center"><i data-lucide="navigation" class="w-4 h-4"></i></a>` : ''}
${r.phone ? `<a href="tel:+34${r.phone}" onclick="event.stopPropagation()" class="w-8 h-8 bg-slate-50 border border-slate-200 text-slate-600 rounded-lg flex items-center justify-center"><i data-lucide="phone" class="w-4 h-4"></i></a>` : ''}
</div>
</div>
`;
}).join('');
lucide.createIcons();
}
function closeProximityMap() {
proximityAbort = true;
document.getElementById('proximityModal').classList.add('hidden');
if (proximityMap) { proximityMap.remove(); proximityMap = null; }
}
</script> </script>
</body> </body>
</html> </html>